From d8a90a39dea9d700af755932a5eda5355b5f12f8 Mon Sep 17 00:00:00 2001
From: Asad Ali <14asadali@gmail.com>
Date: Fri, 30 Oct 2026 17:18:32 +0500
Subject: [PATCH 01/28] feat(sdk)!: generate into the destination itself,
dropping --api-version and --zip
Phase 0 of the custom-code redesign: the SDK directory is the
--destination itself, else /sdk/, 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
---
.ai/plans/custom-code-phases.md | 399 +++++++++++++++++++++
README.md | 13 +-
src/actions/sdk/generate.ts | 62 ++--
src/actions/sdk/publish.ts | 20 +-
src/actions/sdk/publish/interactive.ts | 32 +-
src/actions/sdk/publish/non-interactive.ts | 21 +-
src/commands/sdk/generate.ts | 34 +-
src/commands/sdk/publish.ts | 4 +-
src/infrastructure/file-service.ts | 27 --
src/infrastructure/os-extensions.ts | 6 -
src/infrastructure/terminal.ts | 6 +
src/prompts/sdk/generate.ts | 51 +--
src/prompts/sdk/publish/interactive.ts | 9 +
src/prompts/sdk/publish/non-interactive.ts | 7 -
src/types/build/build.ts | 24 --
src/types/project-context.ts | 64 +---
src/types/sdk-context.ts | 44 +--
src/types/temp-context.ts | 8 +
test/actions/sdk/generate.test.ts | 97 +++--
test/actions/sdk/publish.test.ts | 78 +++-
test/prompts/sdk/generate.test.ts | 39 +-
test/types/project-context.test.ts | 104 +-----
test/types/sdk-context.test.ts | 46 +--
test/types/temp-context.test.ts | 47 +++
24 files changed, 759 insertions(+), 483 deletions(-)
create mode 100644 .ai/plans/custom-code-phases.md
delete mode 100644 src/infrastructure/os-extensions.ts
create mode 100644 src/infrastructure/terminal.ts
delete mode 100644 src/types/build/build.ts
create mode 100644 test/types/temp-context.test.ts
diff --git a/.ai/plans/custom-code-phases.md b/.ai/plans/custom-code-phases.md
new file mode 100644
index 000000000..aaefa5ad8
--- /dev/null
+++ b/.ai/plans/custom-code-phases.md
@@ -0,0 +1,399 @@
+# Custom code, built in vertical slices
+
+Status: planned on `asadali214/custom-code-redesign`, cut from `dev` at `dc3235a1` on
+2026-10-30. Nothing from the first attempt is reused as code; it is reference only.
+
+What the feature does is written down, step by step, in the doc "Custom-code split plan"
+(https://claude.ai/artifact/4ocLd2wGVmgRWfmh2Scme7). This file says in what order it is built,
+one behaviour at a time, so each phase can be run and reviewed on its own.
+
+## 1. How a phase works
+
+- Each phase delivers one behaviour a developer can run, with the tests that prove it. A phase
+ creates only the code that behaviour needs: a class appears the first time a slice has
+ something to hold, a method the first time a slice calls it, a prompt the first time it is said.
+- A phase starts from the working tree as the reviewer left it. Nothing is committed by the
+ agent; the reviewer reads, tailors or refactors the code, and the next phase builds on that.
+- A phase ends with `tsc` on `src` and `test`, ESLint, Prettier on the touched files, the
+ phase's tests, and one full run of the suite.
+- Where a placement is unclear, the agent asks before writing, with options and their
+ trade-offs. Section 4 lists the questions each phase is expected to raise, so the reviewer
+ can answer them ahead of time.
+
+References, on the branch `asadali214/custom-code-rewrite`:
+
+| Commit | Holds | Used for |
+|---|---|---|
+| `533bcf1a` | the flags, the git service, value objects, prompts wording, publish changes, docs | the behaviour and the wording to reproduce; the git plumbing that is known to work |
+| `92988f2f` | the whole run as one file, with every behaviour test | the tests each phase ports: `test/actions/sdk/generate-destination.test.ts`, `generate-write.test.ts`, `test/types/sdk-repository-context-merge.test.ts`, `sdk-repository-context.test.ts`, `sdk-context.test.ts`, `test/infrastructure/git-service.test.ts`, `test/prompts/sdk/generate.test.ts`, `test/types/sdk/*.test.ts` |
+
+A ported test keeps its name and its assertions; only the seam it drives changes to the new
+design.
+
+## 2. Rules for the code
+
+These come from the review of the first attempt and apply to every phase.
+
+- **Functional core, imperative shell.** A step reads every fact it needs first, decides as a
+ pure function over that data, then does its I/O. `execute` is the script of the run and the
+ only place that speaks; the prompts word the data they are handed and decide nothing.
+- **No private instance methods.** A class has `private readonly` fields and public methods.
+ A helper is a `private static` method or a module-level function.
+- **No middlemen.** A method does something with each argument: reads it, transforms it, or
+ combines it with the object's own state. No method only forwards to another call, and no
+ layer re-exposes a service one to one.
+- **Good code is not more functions.** A step's logic stays together where it reads as one
+ thing. A function is split out only when it is used twice, when it separates a decision from
+ I/O, or when its name says something the reader would otherwise have to work out. A one-line
+ call does not get a wrapper.
+- **The repository's conventions** stay: `Promise` with `await` and early returns,
+ typed paths, `ActionResult` as a plain outcome, one prompts class per action, comments only
+ for a why.
+
+## 3. The phases
+
+| Phase | A developer can | Ends as |
+|---|---|---|
+| 0 | generate into the SDK directory itself, with the old flags gone | Success, Failed, Cancelled |
+| 1 | make the SDK directory a repository with `--git-init`, and have the first generation recorded | + git looked for only when wanted |
+| 2 | regenerate and have their edits merged, when nothing conflicts | + conflicts listed, not yet asked about |
+| 3 | choose how a conflict is settled, or resolve it by hand | + Action required |
+| 4 | rerun after leaving conflicts, and be asked what to do with them | + refusals of a repository mid-operation |
+| 5 | regenerate in a clone, which fetches the snapshot it lacks | |
+| 6 | push, and have the snapshots shared by a hook | |
+| 7 | publish an SDK that keeps edits, uploading only what git would commit | |
+| 8 | read the docs for all of it | |
+
+### Phase 0: the SDK directory is the destination itself
+
+The breaking change on its own, before any git: what a run is given, and where it writes.
+
+Behaviour:
+
+1. `sdk generate` takes `--codegen-version`, `--stability`, `--destination`, `--force`,
+ `--input`, `--auth-key`. `--api-version` and `--zip` are gone, and with them the versioned
+ build lookup.
+2. The SDK directory is the `--destination` itself, else ` /sdk/`. One
+ place derives it, for both SDK commands.
+3. The run refuses a destination that contains `src`, since the write empties it.
+4. Without a terminal, the overwrite question is not asked: the run fails, naming `--force`.
+5. `sdk publish` follows: it passes the generation it was given and the directory the project
+ derived. A dry run generates into the SDK directory, not a numbered folder under Downloads.
+
+Touches: the generate command and action; the publish command and the two publish actions;
+`SdkContext` reduced to the directory; `ProjectContext.sdkDirectory(language, destination)`;
+`terminal.isInteractive()`; the flag description for `--force` on the SDK commands; the README
+regenerated. Removed: `BuildConfig`, `versionToBuild`, the Downloads helper, the `zip` save.
+
+Tests: dev's `test/actions/sdk/generate.test.ts` reshaped, plus from `92988f2f`: "writes into
+the destination itself", "refuses … as the destination", "stops before generating when the
+developer declines to overwrite"; `project-context.test.ts` "sdk" cases; `publish.test.ts`
+"runs a dry run on the SDK directory the project names"; `publish-flags.test.ts`.
+
+### Phase 1: the first run records
+
+With `--git-init`, the SDK directory becomes a repository of its own and the generation is
+recorded in it, so the next run has a base.
+
+Behaviour:
+
+1. Git is looked for only when the SDK directory has a `.git` of its own, a folder or a
+ worktree's file, or `--git-init` was given. Otherwise the SDK is saved as in phase 0, and the
+ run ends with the Next steps box: rerun with `--git-init` before changing anything.
+2. Git absent, or older than 2.38: warn that customizations will not persist, then save as
+ before, keeping any `.git`. Git present but not answering: Failed.
+3. A repository counts only when its root is the SDK directory itself. One that only encloses
+ it is treated as none, and nothing is written into it.
+4. `--git-init` on a directory with no repository of its own: `git init` on `main` after the
+ generation succeeded, say so, save, record. A failed init writes nothing.
+5. A repository of its own with no record: say it was found, save, record.
+6. Recording: the generation as a tree of every file, committed under `refs/apimatic/pristine`
+ with the CLI as author; then `.apimatic/.gitattributes` and `.apimatic/generation.json`
+ naming the commit, each written only when changed. A failure here is a warning, since the
+ SDK is on disk.
+7. The overwrite question counts every entry but `.git`, hidden ones included, since the save
+ keeps only `.git`.
+
+Touches: `GitService` with its runner (the `-c` settings, the attributes file for normalised
+line endings, `GIT_DIR` cleared) and the methods `version`, `topLevel`, `init`, `tree` of every
+file, `commitTree`, `resolveRef`, `treeOfCommit`, `updateRef`; `GitVersion`, `CommitId`,
+`TreeId`, `SnapshotRef`, `GenerationRecord`, `GIT_DIRECTORY`; `FileService.hasEntriesExcluding`;
+the `--git-init` flag; the prompts for the warning, the two repository messages, the Next steps,
+the failed init, a failed recording and a directory git could not inspect; the ESLint rule that
+keeps `src/types/sdk/` free of file and process access.
+
+Tests from `92988f2f`: generate-destination "with no git / an older git …" (both), "does not
+look for git without a repository …", the "not a repository of its own" group, "records into
+the SDK directory's own repository …", "asks before replacing a repository … hidden files",
+the "overwrite question" group, "reports a directory git could not inspect"; generate-write
+"overwrite" and "record" groups; sdk-context "destination, by the SDK directory's own
+repository", "destination, and when git is looked for", "exists", "initRepository", "save";
+sdk-repository-context "recordPristine" group and "the generation record" (first three);
+git-service "treeOf" (every file, CRLF stored as LF), `version`, `topLevel`, `init`,
+`commitTree`, `resolveRef`; the prompts tests for the messages added; `generation-record`,
+`snapshot-ref`, `git-version`, `commit-id` value-object tests.
+
+### Phase 2: a regeneration merges
+
+The run finds the record, merges the fresh generation over the directory with git, and writes
+only what changed. Conflicts are found and listed, but not yet asked about: a run that meets
+one fails with the list, as a run without a terminal will always do.
+
+Behaviour:
+
+1. Inspect the repository: the record, whether it is for this generation, whether the commit
+ it names is here. No record, or another generation: record afresh after the overwrite
+ question, with no further warning. The commit missing and no remote: say the snapshot is
+ not available, then record afresh. Under `--force`, a recorded SDK the run cannot merge into
+ is replaced, with a warning after the write that the customizations were not carried over.
+2. Snapshot the generation (every file) and the directory as it is (its ignore rules honoured,
+ except that every path the base holds goes in), both with line endings normalised.
+3. Merge the two over the base with `merge-tree`, renames paired both ways. From git's merge
+ and diffs, know every file to write and remove, each conflict and its kind, and what
+ happened to every other file: added, updated, merged, removed, kept.
+4. Keep the directory as it is, byte for byte, under `refs/apimatic/previous`.
+5. Remove before writing. Write markers relabelled "Your changes" and "Generated by APIMatic",
+ each file with its own line ending (else the generator's), and skip a file whose bytes would
+ not change. Remove the folders a removal leaves empty, below the SDK directory.
+6. A write that fails puts back what it touched from the previous snapshot and names the
+ file; if that fails too, the restore command is given. Failed.
+7. Record the new generation chained on the base; an identical generation keeps the last
+ commit. Then the summary: counts of added, updated, merged and removed files, and the
+ merged and kept files by name.
+8. With conflicts: list them with their kinds and how to rerun; nothing written. Failed.
+
+Touches: `GitService.hasCommit`, `listTree`, `tree` of the working tree (normalised and as
+is), `readBlobs`, `mergeTree`, `diff`; `BlobId`, `RelativeFilePath`, `ValueMap`, the
+`merge-tree` and `diff-tree` output types and parsers; `FileService.getBytes`, `writeBytes`,
+`removeEmptyDirectories`; the merge plan and the summary as pure data; binary and line-ending
+rules; the `recordedSdkReplaced`, `snapshotMissing` (no remote), `sdkMerged`,
+`conflictsNotAsked`, `sdkNotWritten` and `customCodeFailed` prompts.
+
+Tests from `92988f2f`: generate-destination "records over an SDK generated at another
+stability …", "records over an SDK whose snapshot the repository does not hold …",
+"replaces, with --force, …" (both), "merges against the recorded snapshot …";
+generate-write "merge" group except the two marker cases, and "refuses without a terminal,
+naming the conflicts"; the merge table except the marker and settlement cases ("merges your
+edit …", "adds a file …", "accepts one with the same", the renames without markers, "how the
+plan lands on disk", "a write that fails partway", the content rules except "labels the
+markers …" and the binary conflict, "two spec versions" for the files they keep);
+sdk-repository-context "inspect" cases for no record, another generation and a missing
+snapshot; git-service `mergeTree`, `diff`, `listTree`, `readBlobs`, "keeps every byte …";
+`merge-plan.test.ts` for writes, deletes and the summary; `file-content` rules.
+
+### Phase 3: conflicts are asked about
+
+Behaviour:
+
+1. In a terminal, before anything is written: list the conflicts and ask. "I'll resolve them
+ myself", "Resolve using generator changes", "Resolve using my changes", "Cancel this run".
+ Backing out cancels, with nothing written and no previous ref saved.
+2. `--force` answers for the generator without asking.
+3. A chosen side settles each marked file block by block as it is written, keeping every line
+ outside the markers; a file one side deleted, or a binary one, is settled whole: the
+ generator's version where it has one, else yours stays. Say which files were resolved and for
+ which side.
+4. Resolving by hand: the markers stay, the conflicted paths go into git's index at stages 1,
+ 2 and 3 as a merge would leave them, and the steps follow the summary: resolve in a merge
+ tool or by hand, files with markers and without listed apart, then rerun. If the index could
+ not be marked, warn and leave the merge tool out.
+5. The run ends as Action required: a yellow outro, exit 1, so a chained command does not run.
+
+Touches: `ActionResult.actionRequired()` and `isActionRequired()`, the yellow outro and the
+fourth `mapAll` callback; `GitService.stageConflicts`; conflict kinds completed (both changed,
+both added, deleted by you, no longer generated, binary, other), settling markers for a side,
+the plan with a side taken; the `conflictChoice`, `conflictsResolved`, `conflictSteps`,
+`conflictsNotStaged` and `runCancelled` prompts.
+
+Tests from `92988f2f`: generate-write "the question when the merge conflicts" group, "writes
+markers …", "leaves the merge tool out …"; the merge table's marker cases, "settled for a side"
+group, the binary conflict and the two-spec conflicts; `conflict-markers.test.ts`,
+`settlement.test.ts`, `merge-plan.test.ts` for `taking`; `action-result.test.ts`,
+`format.test.ts`; the prompts tests about the conflicts.
+
+### Phase 4: the next run after conflicts were left
+
+Behaviour:
+
+1. Before the record is read, git's unmerged entries below the SDK directory are listed. Any
+ means the last run's conflicts are unresolved, whatever the record says.
+2. Without a terminal and without `--force`: list them, Action required. With `--force`: settle
+ them for the generator. In a terminal: ask. "I'll finish resolving them" ends as Action
+ required; backing out cancels; either side settles.
+3. Settling reads what is on disk and the two sides git kept in the index: markers still there
+ settle in place; a file one side lacks, or a binary one, takes the chosen side whole, the
+ generator's side keeping yours where it has none; text whose markers are gone was resolved
+ by hand and stays. The files then leave the unmerged state.
+4. Inspect again, once; anything still unmerged is listed and the run ends as Action required.
+ Otherwise the run goes on to generate and merge.
+5. 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.
+
+Touches: `GitService.unmergedStages`, `unstage`, `operationInProgress`; `GitOperation`; the
+settling of an unresolved file as a pure rule; the `unresolvedChoice`, `conflictsUnresolved`,
+`resolveLater`, `conflictsNotSettled` prompts and the operation-in-progress wording.
+
+Tests from `92988f2f`: generate-destination "ends as Action required without a terminal …",
+"refuses an unmerged file before looking at the record …", the "question while the last run's
+conflicts are unresolved" group, "refuses while the repository is in the middle of a merge …";
+sdk-repository-context "settling what the last run left unresolved", the "inspect" cases about
+unmerged files and the operation in progress; git-service `unmergedStages`;
+`unresolved-file.test.ts`; `repository-facts.test.ts` for the order of the facts.
+
+### Phase 5: a clone merges against a snapshot it fetches
+
+Behaviour:
+
+1. When the record names a commit the repository lacks and there is a remote (the branch's,
+ else `origin`), fetch `refs/apimatic/pristine` from it, with no prompts, a batch-mode ssh
+ and a 30-second limit.
+2. 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.
+3. The commit here now: merge. Still absent: say the remote lacks it, or why the fetch failed,
+ then the overwrite question and a fresh record.
+4. `--destination` naming a clone of an SDK repository regenerates over the clone.
+
+Touches: `GitService.remoteOf`, `fetchRef`, `isAncestor`, the remote options of the runner;
+`RemoteName`; the chain reconciliation as a pure rule; `snapshotMissing` for the absent and
+failed cases.
+
+Tests from `92988f2f`: generate-destination "merges in a clone only once the pristine refs
+were fetched by hand …", "merges over a clone of an SDK repository named as the destination
+…"; sdk-repository-context "sharing" group; git-service "remotes" group except the hooks
+cases, "leaves an SSH client the developer configured …", "tells an ancestor from a stranger";
+`pristine-chain.test.ts`.
+
+### Phase 6: every push shares the snapshots
+
+Behaviour:
+
+1. After every recording, the script `apimatic-pre-push` is written into git's own hooks
+ directory, executable, and the `pre-push` hook git runs is made to call it: a new shell hook
+ when there is none, the call right after the shebang of an existing shell hook, and nothing
+ when the call is there already.
+2. With `core.hooksPath` set, the CLI writes nothing there. It names the file git runs (beside
+ husky's `_`) and the one line to add, with a commit step when that file is in the
+ repository. A hook in another language is left alone and named the same way.
+3. The script pushes the pristine ref with `--no-verify`; when rejected, it fetches the
+ remote's chain to a tracking ref, joins, and pushes again; a second failure prints one line
+ in the server's words and never fails the push.
+4. The run says once that it wrote the hook, or what one step is still needed, or that the hook
+ could not be written. Later runs are silent.
+
+Touches: `GitService.hooksDirectory`, `hooksPathConfigured`, `hooksRunFrom`;
+`FileService.makeExecutable`; the script and the marked block as constants; the hook decision
+as a pure rule; the `shareHookInstalled`, `shareHookNeedsOneStep` and `shareHookNotInstalled`
+prompts, and the recording notice that chooses one message.
+
+Tests from `92988f2f`: generate-write "the hook that shares snapshots" group;
+sdk-repository-context "the hook that shares snapshots" group (writes once, elsewhere, under
+the home directory, quiet once it calls, another language, shares on push, through husky, a
+clone that never ran the CLI, joins at push time, the server's words, ahead of an existing
+hook); git-service hooks cases; `share-hook.test.ts`, `recording-notice.test.ts`.
+
+### Phase 7: `sdk publish` keeps edits too
+
+Behaviour:
+
+1. `sdk publish` run with flags always passes `--git-init` and never asks. Run without flags, or
+ with `--git-init` alone, it is the step-by-step experience, which asks for the SDK directory
+ after the language and passes the flag on.
+2. The upload holds what git would commit: not `.git`, not `.apimatic`, and not what the SDK's
+ own ignore rules keep out. Outside a repository of its own, everything but `.git` and
+ `.apimatic`.
+3. A generation that ends as Action required publishes nothing, and says the SDK still has
+ conflicts.
+
+Touches: `GitService.ignoredBelow`; `RelativeDirectoryPath` and `RelativePaths`;
+`ZipService.archiveWithout` and `TempContext.zipWithout`; the `--git-init` flag on publish and
+the interactive-mode rule; the `conflictsNotPublished`, `dryRunNotice` and
+`destinationContainsSource` wording on the publish prompts.
+
+Tests from `92988f2f`: `publish.test.ts` ("uploads the SDK without the CLI's record …",
+"publishes nothing, and ends as Action required …", "reports an SDK it could not zip …"),
+`publish-flags.test.ts`; sdk-context "archive" group; git-service `ignoredBelow`;
+`zip-service.test.ts` for the exclusions; `relativeDirectoryPath.test.ts`.
+
+### Phase 8: docs and leftovers
+
+1. `CONTEXT.md` gains the terms: SDK directory, pristine snapshot, generation record, custom
+ code.
+2. The design document `.ai/plans/custom-code.md` from `533bcf1a` comes back with sections 9 and
+ 10 rewritten for the layout the phases produced; the decisions and the lab cases stay.
+3. The README is regenerated, and the convention notes land in the `.ai` instructions and
+ skills.
+4. Optional, to decide at review: the cleanups the first attempt carried that are not this
+ feature. The plugin action and the not-found hooks reading the terminal check instead of
+ `process.stdin.isTTY`; the unused methods on the config, plugin, portal, spec and file
+ classes; the error map on `ServiceError`.
+
+## 4. Questions each phase will raise
+
+Each is asked when its phase reaches it, with these options. An answer written here ahead of
+time saves the question.
+
+**Phase 0: the run's inputs.** The action takes `force` today.
+
+- One `RunMode` value (`force`, whether a question can be asked, later `gitInit`), built by
+ the command. For: one argument, and the publish actions build it the same way. Against: a
+ type for three flags.
+- Separate parameters. For: nothing new. Against: every caller changes with every phase.
+
+**Phase 1: where the inspection lives.** Reading the `.git` on disk, asking git for its
+version and the root, then the record.
+
+- On `SdkContext`, which already holds the directory. For: the directory is the only state
+ these steps need. Against: the class grows a method for each phase.
+- Module-level functions taking the directory, with no class. For: nothing to hold. Against:
+ the project's convention is a context class around a directory.
+
+**Phase 1: the repository as a fact.** Once git says the directory is a repository of its own,
+the recording needs that root.
+
+- A second class holding the root, created only when the root is found. For: "a repository was
+ found" is carried by the type, checked once; every later phase adds to it (inspect, merge,
+ settle, record, hook). Against: two classes around one directory; the first phase would give
+ it two methods.
+- Methods on `SdkContext` that take the root they were handed. For: one class. Against: every
+ caller passes the root the class could have held, which is the middleman shape in reverse.
+- Decide by the end of phase 2, when there are enough methods to see the shape. For: the
+ evidence is in. Against: phase 1 is written one way and may be moved.
+
+**Phase 2: what a merge answers with.** Git's merge becomes a plan of writes, removals,
+conflicts and the summary.
+
+- One class, the plan, holding all of it with the operations on it (take a side, which paths
+ carry markers). For: the merge result is one thing the summary, the settlement and the write
+ all read. Against: it is the largest pure type in the feature.
+- Plain records and functions over them. For: nothing to hold. Against: six functions taking
+ the same record.
+
+**Phase 2: how a write is undone.** The step that lands the plan also puts files back when a
+write fails.
+
+- Landing and undoing in one place, since the undo needs what landing touched. For: the
+ invariant lives in one function. Against: it is the longest function in the shell.
+- Landing returns what it touched, and a separate step restores. For: shorter pieces. Against:
+ a step that only exists for the failure path.
+
+**Phase 3: the end state.** Conflicts left for the developer need a fourth `ActionResult`.
+
+- A new `ActionRequired` kind with exit 1 and a yellow outro, as the product review chose. For:
+ not a success, since the SDK will not build; not a failure, since the run did what was
+ chosen. Against: every `mapAll` caller gains a callback.
+- Reuse `failed` with a message. For: no new kind. Against: `sdk publish` could not tell a
+ conflict from a failure, and the outro would be red for a run that did what was asked.
+
+**Phase 6: where the hook text lives.** The script and the marked block are constants that the
+pure decision and the installer both read.
+
+- Beside the hook decision in `src/types/sdk/`. For: the decision tests read them. Against: a
+ shell script as a TypeScript constant among pure functions.
+- In infrastructure, beside the git service. For: it is git's hooks directory. Against: the
+ decision would import infrastructure, which the ESLint rule forbids.
+
+## 5. Out of scope
+
+As the first attempt decided: an ignore list for frozen files, patch files, code regions,
+portal downloads that include custom code, and any push by the CLI itself. Telemetry for
+conflicts is not sent; the `ActionResult` carries no data.
diff --git a/README.md b/README.md
index 511ab4d36..93e94f623 100644
--- a/README.md
+++ b/README.md
@@ -407,24 +407,21 @@ Generate an SDK for your API
```
USAGE
- $ apimatic sdk generate -l csharp|java|php|python|ruby|typescript|go [-d ] [--api-version ]
- [--zip] [--codegen-version v4] [--stability stable|beta] [-i ] [-f] [-k ]
+ $ apimatic sdk generate -l csharp|java|php|python|ruby|typescript|go [-d ] [--codegen-version v4]
+ [--stability stable|beta] [-i ] [-f] [-k ]
FLAGS
- -d, --destination= [default: /sdk/ | /sdk//] path where
- the SDK will be generated
+ -d, --destination= [default: /sdk/] directory the SDK is generated into
-f, --force overwrite changes without asking for user consent.
-i, --input= [default: ./] path to the parent directory containing the 'src' directory, which
includes API specifications and configuration files.
-k, --auth-key= override current authentication state with an authentication key.
-l, --language= (required) Programming language for SDK generation
- --api-version= Version of the API to use for SDK generation (if multiple versions exist)
--codegen-version= [default: v4] Version of the code generator to use
--stability= Stability level of the generated SDK. Defaults to the level the language offers.
- --zip Download the generated SDK as a .zip archive
DESCRIPTION
Generate an SDK for your API
@@ -437,7 +434,7 @@ EXAMPLES
apimatic sdk generate --language=csharp --input=./
- apimatic sdk generate --language=python --destination=./sdk --zip
+ apimatic sdk generate --language=python --destination=./my-python-sdk
```
_See code: [src/commands/sdk/generate.ts](https://github.com/apimatic/apimatic-cli/blob/dev/src/commands/sdk/generate.ts)_
@@ -453,7 +450,7 @@ USAGE
stable|beta]
FLAGS
- -d, --destination= [default: /sdk] path where the sdk will be generated.
+ -d, --destination= [default: /sdk/] path where the sdk will be generated.
-f, --force overwrite changes without asking for user consent.
-i, --input= [default: ./] path to the parent directory containing the 'src' directory, which
includes API specifications and configuration files.
diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts
index a40f46a33..585b819c0 100644
--- a/src/actions/sdk/generate.ts
+++ b/src/actions/sdk/generate.ts
@@ -7,6 +7,7 @@ import { CommandMetadata } from '../../types/common/command-metadata.js';
import { TempContext } from '../../types/temp-context.js';
import { isAvailableLanguage, Language, Stability } from '../../types/sdk/generate.js';
import { ProjectContext } from '../../types/project-context.js';
+import { SdkContext } from '../../types/sdk-context.js';
export class GenerateAction {
private readonly prompts: SdkGeneratePrompts = new SdkGeneratePrompts();
@@ -21,57 +22,50 @@ export class GenerateAction {
this.authKey = authKey;
}
+ /** `canAsk` is whether a question may be put to the developer at all; `force` answers every question without asking. */
public readonly execute = async (
project: ProjectContext,
- sdkDirectory: DirectoryPath,
+ sdk: DirectoryPath,
language: Language,
stability: Stability,
force: boolean,
- zipSdk: boolean,
- apiVersion?: string,
+ canAsk: boolean,
packageSettingsDirectory?: DirectoryPath
): Promise => {
if (!isAvailableLanguage(language)) {
this.prompts.languageNotAvailable(language);
return ActionResult.failed();
}
-
const sourceDirectory = project.sourceDirectory();
- if (project.isSourceDirectory(sdkDirectory)) {
- this.prompts.sameSourceAndSdkDir(sourceDirectory);
+ // The SDK directory is emptied before the SDK is written, so one holding the source would delete it.
+ if (project.isSourceWithin(sdk)) {
+ this.prompts.destinationContainsSource(sourceDirectory, sdk);
return ActionResult.failed();
}
-
if (!(await project.sourceExists())) {
this.prompts.sourceDirectoryEmpty(sourceDirectory);
return ActionResult.failed();
}
-
- if (apiVersion && !(await project.isVersioned())) {
- this.prompts.apiVersionOnlyApplicableWithVersionedBuild();
- }
-
- const toBuild = await project.versionToBuild(apiVersion, this.prompts.selectVersion);
- if (toBuild.isErr()) {
- this.prompts.noVersionToBuild(toBuild.error, sourceDirectory);
- return ActionResult.failed();
- }
- const buildFrom = toBuild.value;
-
- if (!(await buildFrom.specsExist())) {
+ if (!(await project.specsExist())) {
this.prompts.specDirectoryEmpty(sourceDirectory);
return ActionResult.failed();
}
- const sdkContext = buildFrom.sdk(language, sdkDirectory);
- if (!force && (await sdkContext.exists()) && !(await this.prompts.overwriteSdk(sdkDirectory))) {
- this.prompts.destinationDirNotEmpty();
- return ActionResult.cancelled();
+ const sdkContext = new SdkContext(sdk);
+ if (!force && (await sdkContext.exists())) {
+ if (!canAsk) {
+ this.prompts.cannotAskToOverwrite(sdk);
+ return ActionResult.failed();
+ }
+ if (!(await this.prompts.overwriteSdk(sdk))) {
+ this.prompts.destinationDirNotEmpty();
+ return ActionResult.cancelled();
+ }
}
return await withDirPath(async (tempDirectory) => {
const tempContext = new TempContext(tempDirectory);
- const build = await buildFrom.buildZip(tempDirectory, packageSettingsDirectory);
+ const build = await project.buildZip(tempDirectory, packageSettingsDirectory);
if (build.isErr()) {
this.prompts.srcDirNotZipped(sourceDirectory, build.error);
return ActionResult.failed();
@@ -87,26 +81,20 @@ export class GenerateAction {
this.authKey
)
);
-
if (response.isErr()) {
this.prompts.sdkGenerationServiceError(response.error);
return ActionResult.failed();
}
- const responseSdkZipPath = await tempContext.save(response.value);
- const tempSdk = await sdkContext.loadSdkInTempDirectory(tempDirectory, responseSdkZipPath);
- if (tempSdk.isErr()) {
- this.prompts.sdkNotUnzipped(tempSdk.error);
- return ActionResult.failed();
- }
-
- const saved = await sdkContext.save(tempSdk.value, zipSdk);
- if (saved.isErr()) {
- this.prompts.sdkNotSaved(saved.error);
+ const archive = await tempContext.save(response.value);
+ const generated = await tempContext.unzip(archive);
+ if (generated.isErr()) {
+ this.prompts.sdkNotUnzipped(generated.error);
return ActionResult.failed();
}
- this.prompts.sdkGenerated(saved.value);
+ const saved = await sdkContext.save(generated.value);
+ this.prompts.sdkGenerated(saved);
return ActionResult.success();
});
};
diff --git a/src/actions/sdk/publish.ts b/src/actions/sdk/publish.ts
index 2827c4adf..79f6430b3 100644
--- a/src/actions/sdk/publish.ts
+++ b/src/actions/sdk/publish.ts
@@ -27,15 +27,16 @@ export class SdkPublishAction {
public readonly execute = async (
project: ProjectContext,
- outputDirectory: DirectoryPath,
+ sdkDirectory: DirectoryPath,
language: Language,
+ stability: Stability,
publishType: PublishType[],
force: boolean,
+ canAsk: boolean,
profileId: ProfileId,
semVersion: SemVersion,
publishingProfile: PublishingProfile,
dryRun: boolean,
- stability: Stability,
publishingSummary: string,
onPublishSdkError: (errorMessage: string) => void
): Promise => {
@@ -52,12 +53,11 @@ export class SdkPublishAction {
const sdkGenerateAction = new GenerateAction(this.configDir, this.commandMetadata);
const sdkGenerationResult = await sdkGenerateAction.execute(
project,
- outputDirectory,
+ sdkDirectory,
language,
stability,
force,
- false,
- undefined,
+ canAsk,
packageSettingsDirectory
);
if (sdkGenerationResult.isFailed()) {
@@ -67,19 +67,17 @@ export class SdkPublishAction {
return ActionResult.cancelled();
}
- const sdkLanguageDirectory = outputDirectory.join(language);
-
if (dryRun) {
this.prompts.dryRunNotice(publishingSummary);
- const readmeFilePath = new FilePath(sdkLanguageDirectory, new FileName('README.md'));
- await this.launcherService.openDirectoryInEditorOrFileExplorer(sdkLanguageDirectory, readmeFilePath);
+ const readmeFilePath = new FilePath(sdkDirectory, new FileName('README.md'));
+ await this.launcherService.openDirectoryInEditorOrFileExplorer(sdkDirectory, readmeFilePath);
return ActionResult.success();
}
const tempContext = new TempContext(tempDirectory);
- const sdkFile = await tempContext.zip(sdkLanguageDirectory);
+ const sdkFile = await tempContext.zip(sdkDirectory);
if (sdkFile.isErr()) {
- this.prompts.sdkNotZipped(sdkLanguageDirectory, sdkFile.error);
+ this.prompts.sdkNotZipped(sdkDirectory, sdkFile.error);
return ActionResult.failed();
}
diff --git a/src/actions/sdk/publish/interactive.ts b/src/actions/sdk/publish/interactive.ts
index 278627dc4..8c9a469a7 100644
--- a/src/actions/sdk/publish/interactive.ts
+++ b/src/actions/sdk/publish/interactive.ts
@@ -33,15 +33,6 @@ export class SdkPublishInteractiveAction {
}
const project = ProjectContext.in(workingDirectory);
- const sdkDirectory = await this.prompts.inputSdkDirectory(
- project.sdkDirectory(),
- SdkPublishInteractiveAction.sdkDirectoryValidator(project)
- );
- if (!sdkDirectory) {
- await this.prompts.noSdkDirectoryProvided();
- return ActionResult.cancelled();
- }
-
const publishingProfilesResponse = await this.prompts.getPublishingProfiles(
this.publishingApiService.getPublishingProfiles(this.configDir, this.commandMetadata.shell)
);
@@ -86,6 +77,18 @@ export class SdkPublishInteractiveAction {
return ActionResult.cancelled();
}
+ const sdkDirectory = await this.prompts.inputSdkDirectory(project.sdkDirectory(language), (value) => {
+ if (!value) return;
+ const directory = DirectoryPath.fromUserInput(value);
+ if (project.isSourceWithin(directory)) {
+ return this.prompts.destinationContainsSource(project.sourceDirectory(), directory);
+ }
+ });
+ if (!sdkDirectory) {
+ await this.prompts.noSdkDirectoryProvided();
+ return ActionResult.cancelled();
+ }
+
const levels = stabilityLevelsFor(language);
const stability = levels.length === 1 ? levels[0] : await this.prompts.selectStability(levels);
if (!stability) {
@@ -126,13 +129,14 @@ export class SdkPublishInteractiveAction {
project,
sdkDirectory,
language,
+ stability,
publishTypes,
false,
+ true,
publishingProfileId,
version,
publishingProfile,
false,
- stability,
publishingSummary,
onPublishSdkError
);
@@ -157,12 +161,4 @@ export class SdkPublishInteractiveAction {
return "The 'src' directory does not exist at the provided location. Please check the path and try again.";
};
}
-
- public static sdkDirectoryValidator(project: ProjectContext): (value: string | undefined) => string | undefined {
- return (value) => {
- if (!value) return;
- if (project.isSourceDirectory(DirectoryPath.fromUserInput(value)))
- return 'SDK directory must be different from the src directory.';
- };
- }
}
diff --git a/src/actions/sdk/publish/non-interactive.ts b/src/actions/sdk/publish/non-interactive.ts
index f4b4b7551..e81f78411 100644
--- a/src/actions/sdk/publish/non-interactive.ts
+++ b/src/actions/sdk/publish/non-interactive.ts
@@ -8,24 +8,21 @@ import { PublishingProfile } from '../../../types/publish/publishing-profile.js'
import { Language } from '../../../types/sdk/generate.js';
import { StabilityChoice } from '../../../types/sdk/stability-choice.js';
import { ActionResult } from '../../action-result.js';
-import { getDownloadsDirectory } from '../../../infrastructure/os-extensions.js';
import { SemVersion } from '../../../types/publish/version.js';
import { ProfileId } from '../../../types/publish/profile-id.js';
import { ProjectContext } from '../../../types/project-context.js';
import { RecordPublishedSdkAction } from '../record-published-sdk.js';
import { SdkPublishAction } from '../publish.js';
-import { FileService } from '../../../infrastructure/file-service.js';
export class SdkPublishNonInteractiveAction {
private readonly prompts: SdkPublishNonInteractivePrompts = new SdkPublishNonInteractivePrompts();
private readonly publishingApiService: PublishingApiService = new PublishingApiService();
- private readonly fileService: FileService = new FileService();
public constructor(private readonly configDir: DirectoryPath, private readonly commandMetadata: CommandMetadata) {}
public readonly execute = async (
project: ProjectContext,
- sdkDirectory: DirectoryPath,
+ destination: DirectoryPath | undefined,
language: Language,
publishTypes: PublishType[],
force: boolean,
@@ -35,11 +32,6 @@ export class SdkPublishNonInteractiveAction {
profileId?: string,
version?: string
): Promise => {
- if (project.isSourceDirectory(sdkDirectory)) {
- this.prompts.directoryCannotBeSame(sdkDirectory);
- return ActionResult.failed();
- }
-
if (!(await project.sourceExists())) {
this.prompts.sourceDirectoryDoesNotExist(project.sourceDirectory());
return ActionResult.failed();
@@ -56,6 +48,8 @@ export class SdkPublishNonInteractiveAction {
return ActionResult.failed();
}
+ const sdkDirectory = project.sdkDirectory(language, destination);
+
const semVersionResult = SemVersion.tryCreate(version!);
if (semVersionResult.isErr()) {
this.prompts.invalidVersion(version!);
@@ -113,20 +107,19 @@ export class SdkPublishNonInteractiveAction {
// Named only when the user chose it, so a run that took the default reads as the doc does.
stability: stability.chosenLevel()
});
- const outputDir = dryRun
- ? await this.fileService.getAvailableDirectoryPath(getDownloadsDirectory('apimatic-sdk'))
- : sdkDirectory;
+ // A run with flags is the CI flow: it never waits on a question, whether a terminal is attached or not.
const publishResult = await new SdkPublishAction(this.configDir, this.commandMetadata).execute(
project,
- outputDir,
+ sdkDirectory,
language,
+ stability.stabilityLevel(),
publishTypes,
force,
+ false,
publishingProfileId,
semVersion,
publishingProfile,
dryRun,
- stability.stabilityLevel(),
publishingSummary,
onPublishSdkError
);
diff --git a/src/commands/sdk/generate.ts b/src/commands/sdk/generate.ts
index 5ccf62d0b..f06439ebc 100644
--- a/src/commands/sdk/generate.ts
+++ b/src/commands/sdk/generate.ts
@@ -2,6 +2,7 @@ import { Command, Flags } from '@oclif/core';
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { FlagsProvider } from '../../types/flags-provider.js';
import { GenerateAction } from '../../actions/sdk/generate.js';
+import { terminal } from '../../infrastructure/terminal.js';
import {
AVAILABLE_LANGUAGES,
CodeGenerationVersion,
@@ -37,15 +38,7 @@ ${inProse(AVAILABLE_LANGUAGES)} are available; ${inProse(UPCOMING_LANGUAGES)} ar
}),
destination: Flags.string({
char: 'd',
- description:
- '[default: /sdk/ | /sdk//] path where the SDK will be generated'
- }),
- 'api-version': Flags.string({
- description: 'Version of the API to use for SDK generation (if multiple versions exist)'
- }),
- zip: Flags.boolean({
- default: false,
- description: 'Download the generated SDK as a .zip archive'
+ description: '[default: /sdk/] directory the SDK is generated into'
}),
'codegen-version': Flags.string({
description: 'Version of the code generator to use',
@@ -64,23 +57,12 @@ ${inProse(AVAILABLE_LANGUAGES)} are available; ${inProse(UPCOMING_LANGUAGES)} ar
static examples = [
`${SdkGenerate.cmdTxt} ${format.flag('language', 'typescript')}`,
`${SdkGenerate.cmdTxt} ${format.flag('language', 'csharp')} ${format.flag('input', './')}`,
- `${SdkGenerate.cmdTxt} ${format.flag('language', 'python')} ${format.flag('destination', './sdk')} ${format.flag(
- 'zip'
- )}`
+ `${SdkGenerate.cmdTxt} ${format.flag('language', 'python')} ${format.flag('destination', './my-python-sdk')}`
];
async run() {
const {
- flags: {
- language,
- input,
- destination,
- force,
- zip: zipSdk,
- stability,
- 'auth-key': authKey,
- 'api-version': apiVersion
- }
+ flags: { language, input, destination, force, stability, 'auth-key': authKey }
} = await this.parse(SdkGenerate);
const project = ProjectContext.at(input);
@@ -94,12 +76,14 @@ ${inProse(AVAILABLE_LANGUAGES)} are available; ${inProse(UPCOMING_LANGUAGES)} ar
const action = new GenerateAction(this.getConfigDir(), commandMetadata, authKey);
const result = await action.execute(
project,
- project.sdkDirectory(destination),
+ project.sdkDirectory(
+ language as Language,
+ destination === undefined ? undefined : DirectoryPath.fromUserInput(destination)
+ ),
language as Language,
StabilityChoice.for(language as Language, stability).stabilityLevel(),
force,
- zipSdk,
- apiVersion
+ terminal.isInteractive()
);
outro(result);
}
diff --git a/src/commands/sdk/publish.ts b/src/commands/sdk/publish.ts
index 20cb842d9..cca57aba3 100644
--- a/src/commands/sdk/publish.ts
+++ b/src/commands/sdk/publish.ts
@@ -28,7 +28,7 @@ export default class SdkPublish extends Command {
char: 'v',
description: 'Semantic version of the SDK to publish (e.g. 1.0.0).'
}),
- ...FlagsProvider.destination('sdk', 'sdk'),
+ ...FlagsProvider.destination('sdk/', 'sdk'),
language: Flags.string({
char: 'l',
description: 'Language of the SDK to generate and publish.',
@@ -134,7 +134,7 @@ export default class SdkPublish extends Command {
)
: await new SdkPublishNonInteractiveAction(configDir, commandMetadata).execute(
project,
- project.sdkDirectory(destination),
+ destination === undefined ? undefined : DirectoryPath.fromUserInput(destination),
language as Language,
publishTypes,
force,
diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts
index f7a078ec6..0b3122a21 100644
--- a/src/infrastructure/file-service.ts
+++ b/src/infrastructure/file-service.ts
@@ -373,33 +373,6 @@ export class FileService {
return false;
}
}
-
- public async getAvailableDirectoryPath(currentPath: DirectoryPath): Promise {
- if (!(await this.directoryExists(currentPath))) return currentPath;
-
- const dir = path.dirname(currentPath.toString());
- let baseName = path.basename(currentPath.toString());
-
- // Strip existing " (n)" suffix using string methods
- if (baseName.endsWith(')')) {
- const open = baseName.lastIndexOf('(');
- if (open !== -1 && baseName[open - 1] === ' ') {
- const inner = baseName.slice(open + 1, -1);
- if (inner.length > 0 && !isNaN(Number(inner))) {
- baseName = baseName.slice(0, open - 1);
- }
- }
- }
-
- let counter = 1;
- let newPath: string;
-
- do {
- newPath = path.join(dir, `${baseName} (${counter++})`);
- } while (await this.directoryExists(new DirectoryPath(newPath)));
-
- return new DirectoryPath(newPath);
- }
}
const streamPipeline = promisify(pipeline);
diff --git a/src/infrastructure/os-extensions.ts b/src/infrastructure/os-extensions.ts
deleted file mode 100644
index b7aa6bc29..000000000
--- a/src/infrastructure/os-extensions.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import os from 'os';
-import { DirectoryPath } from '../types/file/directoryPath.js';
-
-export function getDownloadsDirectory(prefix= 'apimatic'): DirectoryPath {
- return new DirectoryPath(os.homedir()).join('Downloads').join(prefix);
-}
diff --git a/src/infrastructure/terminal.ts b/src/infrastructure/terminal.ts
new file mode 100644
index 000000000..c254235dd
--- /dev/null
+++ b/src/infrastructure/terminal.ts
@@ -0,0 +1,6 @@
+/** Whether a question can be asked at all: a terminal is attached, so a CI job or a pipe never waits on one. */
+export const terminal = {
+ isInteractive(): boolean {
+ return Boolean(process.stdin.isTTY);
+ }
+};
diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts
index 417a0eb8e..4d6c0eb85 100644
--- a/src/prompts/sdk/generate.ts
+++ b/src/prompts/sdk/generate.ts
@@ -1,4 +1,4 @@
-import { isCancel, confirm, log, select } from '@clack/prompts';
+import { isCancel, confirm, log } from '@clack/prompts';
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { FileProblem } from '../../types/file/file-problem.js';
import { format as f } from '../format.js';
@@ -6,8 +6,6 @@ import { Result } from 'neverthrow';
import { notZippedForUpload, withSpinner } from '../prompt.js';
import { ServiceError } from '../../infrastructure/service-error.js';
import { AVAILABLE_LANGUAGES, Language, languageLabel, UPCOMING_LANGUAGES } from '../../types/sdk/generate.js';
-import { VersionProblem } from '../../types/project-context.js';
-import { SdkSaveProblem } from '../../types/sdk-context.js';
const names = (languages: readonly Language[]) => languages.map(languageLabel).join(', ');
@@ -25,6 +23,14 @@ export class SdkGeneratePrompts {
);
}
+ public destinationContainsSource(sourceDirectory: DirectoryPath, sdkDirectory: DirectoryPath) {
+ log.error(
+ `The destination ${f.path(sdkDirectory)} contains your source directory ` +
+ `${f.path(sourceDirectory)}, and everything in the destination is replaced by the ` +
+ `generated SDK. Choose a destination outside it, such as ${f.flag('destination', './sdk')}.`
+ );
+ }
+
public async overwriteSdk(directory: DirectoryPath): Promise {
const overwrite = await confirm({
message: `The destination ${f.path(directory)} is not empty, do you want to overwrite?`,
@@ -38,10 +44,12 @@ export class SdkGeneratePrompts {
return overwrite;
}
- public sameSourceAndSdkDir(directory: DirectoryPath) {
- const message =
- `The ${f.var('src')} and ${f.var('sdk')} directories must be different. ` + `Current value: ${f.path(directory)}`;
- log.error(message);
+ /** The overwrite question with no one to answer it. */
+ public cannotAskToOverwrite(directory: DirectoryPath) {
+ log.error(
+ `The destination ${f.path(directory)} is not empty, and this run cannot ask whether to overwrite it. ` +
+ `Rerun in a terminal to be asked, or with ${f.flag('force')} to overwrite.`
+ );
}
public sourceDirectoryEmpty(directory: DirectoryPath) {
@@ -75,35 +83,6 @@ export class SdkGeneratePrompts {
log.error(`${ServiceError.InvalidResponse.errorMessage}\n${problem.reason}`);
}
- public sdkNotSaved(problem: SdkSaveProblem) {
- log.error(`The SDK could not be saved to ${f.path(problem.sdkDirectory)}: ${problem.reason}`);
- }
-
- public noVersionToBuild(problem: VersionProblem, sourceDirectory: DirectoryPath) {
- const messages: Record = {
- noVersions: `The ${f.var('versioned_docs')} directory is either empty or invalid: ${f.path(sourceDirectory)}`,
- versionNotFound: 'The selected API version is invalid.'
- };
- log.error(messages[problem]);
- }
-
- public apiVersionOnlyApplicableWithVersionedBuild() {
- log.warn(`The ${f.flag('api-version')} is only applicable with a versioned build.`);
- }
-
- public async selectVersion(versions: string[]): Promise {
- const version = await select({
- message: 'Select an API version for SDK generation:',
- options: versions.map((v) => ({ label: v, value: v }))
- });
-
- if (isCancel(version)) {
- return undefined;
- }
-
- return version;
- }
-
public sdkGenerated(sdk: DirectoryPath) {
log.info(`The generated SDK can be found at ${f.relativePath(sdk)}.`);
}
diff --git a/src/prompts/sdk/publish/interactive.ts b/src/prompts/sdk/publish/interactive.ts
index fc5d729f3..20c94c141 100644
--- a/src/prompts/sdk/publish/interactive.ts
+++ b/src/prompts/sdk/publish/interactive.ts
@@ -15,6 +15,15 @@ import { SemVersion } from '../../../types/publish/version.js';
import { SDK_PUBLISHING_OVERVIEW_URL } from '../../publishing/links.js';
export class SdkPublishInteractivePrompts {
+ /** What the directory question answers with while a destination holding the source is typed. */
+ public destinationContainsSource(sourceDirectory: DirectoryPath, sdkDirectory: DirectoryPath): string {
+ return (
+ `The destination ${f.path(sdkDirectory)} contains your source directory ` +
+ `${f.path(sourceDirectory)}, and everything in the destination is replaced by the ` +
+ `generated SDK. Choose a destination outside it, such as ${f.flag('destination', './sdk')}.`
+ );
+ }
+
public async inputWorkingDirectory(
defaultDirectory: DirectoryPath,
validator: (value: string | undefined) => string | Error | undefined
diff --git a/src/prompts/sdk/publish/non-interactive.ts b/src/prompts/sdk/publish/non-interactive.ts
index b11455d30..f3f10d559 100644
--- a/src/prompts/sdk/publish/non-interactive.ts
+++ b/src/prompts/sdk/publish/non-interactive.ts
@@ -12,13 +12,6 @@ import { SDK_PUBLISHING_OVERVIEW_URL } from '../../publishing/links.js';
import { APIMATIC_CONFIG_FILE_NAME } from '../../../types/apimatic-config/document.js';
export class SdkPublishNonInteractivePrompts {
- public directoryCannotBeSame(directory: DirectoryPath) {
- const message = `The ${f.var('src')} and ${f.var('sdk')} directories must be different. Current value: ${f.path(
- directory
- )}`;
- log.error(message);
- }
-
public sourceDirectoryDoesNotExist(directory: DirectoryPath) {
log.error(`The ${f.var('src')} does not exist at the provided location: ${f.path(directory)}`);
}
diff --git a/src/types/build/build.ts b/src/types/build/build.ts
deleted file mode 100644
index d8d040e2d..000000000
--- a/src/types/build/build.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-export interface BuildConfigData {
- generateVersionedPortal?: object;
- versionsPath?: string;
-}
-
-// Immutable wrapper around the parsed APIMATIC-BUILD.json. Since version 2 the portal is
-// configured by `src/apimatic.json` and built locally, so this file only describes SDK and
-// plugin generation.
-export class BuildConfig {
- private constructor(private readonly data: BuildConfigData) {}
-
- public static parse(json: string): BuildConfig {
- return new BuildConfig(JSON.parse(json) as BuildConfigData);
- }
-
- public isVersioned(): boolean {
- return this.data.generateVersionedPortal != null;
- }
-
- /** Directory holding the versioned builds, relative to the source directory. Defaults to "versioned_docs". */
- public versionsPath(): string {
- return this.data.versionsPath ?? 'versioned_docs';
- }
-}
diff --git a/src/types/project-context.ts b/src/types/project-context.ts
index dadff7452..1755709c0 100644
--- a/src/types/project-context.ts
+++ b/src/types/project-context.ts
@@ -2,7 +2,6 @@ import { err, ok, Result } from 'neverthrow';
import { FileService } from '../infrastructure/file-service.js';
import { errorMessage } from '../utils/error-utils.js';
import { ApimaticConfigContext } from './apimatic-config-context.js';
-import { BuildConfig } from './build/build.js';
import { DirectoryPath } from './file/directoryPath.js';
import { FileName } from './file/fileName.js';
import { FilePath } from './file/filePath.js';
@@ -17,7 +16,6 @@ import {
SPEC_DIRECTORY_NAME
} from './project-layout.js';
import { Language } from './sdk/generate.js';
-import { SdkContext } from './sdk-context.js';
import { SpecContext } from './spec-context.js';
import { TempContext } from './temp-context.js';
@@ -29,8 +27,6 @@ export const GENERATED: readonly string[] = Object.values(OUTPUT_DIRECTORY_NAMES
/** Neither stops a portal being built, so the wizard says so and carries on. */
export type GitignoreFailure = 'unreadable' | 'unwritable';
-export type VersionProblem = 'noVersions' | 'versionNotFound';
-
export type SpecZipProblem =
| { kind: 'noSpec'; specDirectory: DirectoryPath }
| { kind: 'unreadable'; specDirectory: DirectoryPath; reason: string }
@@ -40,11 +36,7 @@ export type SpecZipProblem =
export class ProjectContext {
private readonly fileService = new FileService();
- private constructor(
- private readonly projectDirectory: DirectoryPath,
- private readonly source: DirectoryPath,
- private readonly version?: string
- ) {}
+ private constructor(private readonly projectDirectory: DirectoryPath, private readonly source: DirectoryPath) {}
public static at(input: string | undefined): ProjectContext {
return ProjectContext.in(DirectoryPath.createInput(input));
@@ -54,10 +46,6 @@ export class ProjectContext {
return new ProjectContext(projectDirectory, projectDirectory.join(SOURCE_DIRECTORY_NAME));
}
- private get buildFile(): FilePath {
- return new FilePath(this.source, new FileName('APIMATIC-BUILD.json'));
- }
-
private get gitignore(): FilePath {
return new FilePath(this.projectDirectory, new FileName(GITIGNORE));
}
@@ -78,8 +66,9 @@ export class ProjectContext {
return directory.contains(this.source);
}
- public sdkDirectory(destination?: string): DirectoryPath {
- return this.outputDirectory(OUTPUT_DIRECTORY_NAMES.sdk, destination);
+ /** The SDK directory itself: the `--destination` when one is named, else `sdk/` beside the source. */
+ public sdkDirectory(language: Language, destination?: DirectoryPath): DirectoryPath {
+ return destination ?? this.projectDirectory.join(OUTPUT_DIRECTORY_NAMES.sdk, language);
}
public portalDirectory(destination?: string): DirectoryPath {
@@ -146,40 +135,6 @@ export class ProjectContext {
return await new TempContext(tempDirectory).zip(staged.value);
}
- public async isVersioned(): Promise {
- const buildConfig = await this.buildConfig();
- return buildConfig !== undefined && buildConfig.isVersioned();
- }
-
- public async versionToBuild(
- apiVersion: string | undefined,
- ask: (versions: string[]) => Promise
- ): Promise> {
- const buildConfig = await this.buildConfig();
- if (!buildConfig?.isVersioned()) {
- return ok(this);
- }
-
- const versionsDirectory = this.source.join(buildConfig.versionsPath());
- const versions = (await this.fileService.directoryExists(versionsDirectory))
- ? await this.fileService.getSubDirectoriesPaths(versionsDirectory)
- : [];
- if (versions.length === 0) {
- return err('noVersions');
- }
- if (!apiVersion && versions.length === 1) {
- return ok(this.reading(versions[0]));
- }
-
- const chosen = apiVersion || (await ask(versions.map((version) => version.leafName())));
- const version = versions.find((directory) => directory.leafName() === chosen);
- return version === undefined ? err('versionNotFound') : ok(this.reading(version));
- }
-
- public sdk(language: Language, sdkDirectory: DirectoryPath): SdkContext {
- return new SdkContext(language, sdkDirectory, this.version);
- }
-
public portalSource(): PortalSourceContext {
return new PortalSourceContext(this.source);
}
@@ -240,18 +195,7 @@ export class ProjectContext {
}
}
- private reading(versionDirectory: DirectoryPath): ProjectContext {
- return new ProjectContext(this.projectDirectory, versionDirectory, versionDirectory.leafName());
- }
-
private outputDirectory(name: string, destination: string | undefined): DirectoryPath {
return destination ? new DirectoryPath(destination) : this.projectDirectory.join(name);
}
-
- private async buildConfig(): Promise {
- if (!(await this.fileService.fileExists(this.buildFile))) {
- return undefined;
- }
- return BuildConfig.parse(await this.fileService.getContents(this.buildFile));
- }
}
diff --git a/src/types/sdk-context.ts b/src/types/sdk-context.ts
index 163d9ae83..d0dc30b72 100644
--- a/src/types/sdk-context.ts
+++ b/src/types/sdk-context.ts
@@ -1,50 +1,20 @@
-import { ok, Result } from 'neverthrow';
import { FileService } from '../infrastructure/file-service.js';
import { DirectoryPath } from './file/directoryPath.js';
-import { FilePath } from './file/filePath.js';
-import { FileName } from './file/fileName.js';
-import { FileProblem } from './file/file-problem.js';
-import { Language } from './sdk/generate.js';
-import { ZipService } from '../infrastructure/zip-service.js';
-
-export type SdkSaveProblem = FileProblem & { sdkDirectory: DirectoryPath };
+/** The directory an SDK is generated into: the `--destination` itself, or `sdk/` beside the source. */
export class SdkContext {
private readonly fileService = new FileService();
- private readonly zipService = new ZipService();
- private readonly sdkDirectory: DirectoryPath;
-
- constructor(private readonly language: Language, sdkDirectory: DirectoryPath, version?: string) {
- this.sdkDirectory = version ? sdkDirectory.join(version).join(language) : sdkDirectory.join(language);
- }
- private get zipPath(): FilePath {
- return new FilePath(this.sdkDirectory, new FileName(`${this.language}.zip`));
- }
+ constructor(private readonly sdkDirectory: DirectoryPath) {}
- public async exists() {
+ public async exists(): Promise {
return !(await this.fileService.directoryEmpty(this.sdkDirectory));
}
- public async save(tempSdkDirectory: DirectoryPath, zipSdk: boolean): Promise> {
+ /** Replaces whatever the directory holds with the generated SDK. */
+ public async save(generated: DirectoryPath): Promise {
await this.fileService.cleanDirectory(this.sdkDirectory);
- if (!zipSdk) {
- await this.fileService.copyDirectoryContents(tempSdkDirectory, this.sdkDirectory);
- return ok(this.sdkDirectory);
- }
- const archived = await this.zipService.archive(tempSdkDirectory, this.zipPath);
- return archived
- .map(() => this.sdkDirectory)
- .mapErr((reason): SdkSaveProblem => ({ kind: 'zipFailed', reason, sdkDirectory: this.sdkDirectory }));
- }
-
- public async loadSdkInTempDirectory(
- tempDirectory: DirectoryPath,
- tempSdk: FilePath
- ): Promise> {
- const tempSdkDirectory = tempDirectory.join('sdk-original');
- await this.fileService.createDirectoryIfNotExists(tempSdkDirectory);
- const unpacked = await this.zipService.unArchive(tempSdk, tempSdkDirectory);
- return unpacked.map(() => tempSdkDirectory).mapErr((reason): FileProblem => ({ kind: 'unzipFailed', reason }));
+ await this.fileService.copyDirectoryContents(generated, this.sdkDirectory);
+ return this.sdkDirectory;
}
}
diff --git a/src/types/temp-context.ts b/src/types/temp-context.ts
index 2e0d6ba97..07b9a0eee 100644
--- a/src/types/temp-context.ts
+++ b/src/types/temp-context.ts
@@ -24,6 +24,14 @@ export class TempContext {
return archived.map(() => tempFile).mapErr((reason): FileProblem => ({ kind: 'zipFailed', reason }));
}
+ /** The archive expanded into a directory of its own below the temp directory. */
+ public async unzip(archive: FilePath): Promise> {
+ const directory = this.tempDirectory.join(`${archive.name()}-unzipped`);
+ await this.fileService.createDirectoryIfNotExists(directory);
+ const unpacked = await this.zipService.unArchive(archive, directory);
+ return unpacked.map(() => directory).mapErr((reason): FileProblem => ({ kind: 'unzipFailed', reason }));
+ }
+
public async save(portalStream: NodeJS.ReadableStream): Promise {
const tempFile = this.getTempFileName;
await this.fileService.writeFile(tempFile, portalStream);
diff --git a/test/actions/sdk/generate.test.ts b/test/actions/sdk/generate.test.ts
index 8fc68c54d..21151cc36 100644
--- a/test/actions/sdk/generate.test.ts
+++ b/test/actions/sdk/generate.test.ts
@@ -23,25 +23,26 @@ const COMMAND_METADATA: CommandMetadata = { commandName: 'sdk generate', shell:
describe('GenerateAction (sdk)', () => {
let root: string;
- let sdkDirectory: DirectoryPath;
let sdkArchive: Buffer;
let generateSdk: sinon.SinonStub;
- const execute = (zipSdk = false) =>
+ const sdk = () => new DirectoryPath(path.join(root, 'sdk', 'python'));
+ const at = (relative: string) => path.join(sdk().toString(), relative);
+
+ const execute = (options: { force?: boolean; canAsk?: boolean; destination?: DirectoryPath } = {}) =>
new GenerateAction(new DirectoryPath(root), COMMAND_METADATA, 'auth-key').execute(
ProjectContext.in(new DirectoryPath(root)),
- sdkDirectory,
+ options.destination ?? sdk(),
Language.PYTHON,
Stability.STABLE,
- true,
- zipSdk
+ options.force ?? true,
+ options.canAsk ?? false
);
beforeEach(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-generate-'));
fs.mkdirSync(path.join(root, 'src', 'spec'), { recursive: true });
fs.writeFileSync(path.join(root, 'src', 'spec', 'openapi.json'), '{}');
- sdkDirectory = new DirectoryPath(path.join(root, 'sdk'));
const generated = path.join(root, 'generated');
fs.mkdirSync(generated);
@@ -61,12 +62,36 @@ describe('GenerateAction (sdk)', () => {
fs.rmSync(root, { recursive: true, force: true });
});
- it('expands the generated SDK into its language directory', async () => {
+ it('expands the generated SDK into the SDK directory', async () => {
expect((await execute()).isSuccess()).to.be.true;
- expect(fs.readFileSync(path.join(sdkDirectory.toString(), 'python', 'README.md'), 'utf-8')).to.equal('# sdk');
+ expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# sdk');
});
+ it('writes into the destination itself, with no language directory below it', async () => {
+ const destination = new DirectoryPath(path.join(root, 'my-python-sdk'));
+
+ expect((await execute({ destination })).isSuccess()).to.be.true;
+
+ expect(fs.readFileSync(path.join(destination.toString(), 'README.md'), 'utf-8')).to.equal('# sdk');
+ expect(fs.existsSync(path.join(destination.toString(), 'python'))).to.be.false;
+ });
+
+ for (const [named, destination] of [
+ ['the project', () => new DirectoryPath(root)],
+ ['the source directory', () => new DirectoryPath(path.join(root, 'src'))]
+ ] as const) {
+ it(`refuses ${named} as the destination, since writing the SDK would delete the source`, async () => {
+ const destinationContainsSource = sinon.stub(SdkGeneratePrompts.prototype, 'destinationContainsSource');
+
+ expect((await execute({ destination: destination() })).isFailed()).to.be.true;
+
+ expect(destinationContainsSource.calledOnce).to.be.true;
+ expect(generateSdk.called).to.be.false;
+ expect(fs.existsSync(path.join(root, 'src', 'spec', 'openapi.json'))).to.be.true;
+ });
+ }
+
it('reports a source directory it could not zip, without generating', async () => {
const problem: FileProblem = { kind: 'zipFailed', reason: 'EACCES: permission denied' };
sinon.stub(TempContext.prototype, 'zip').resolves(err(problem));
@@ -87,18 +112,50 @@ describe('GenerateAction (sdk)', () => {
expect(problem.reason).to.not.be.empty;
});
- it('reports an SDK it could not save, naming its language directory', async () => {
- const archive = sinon.stub(ZipService.prototype, 'archive').callThrough();
- archive.onSecondCall().resolves(err('ENOSPC: no space left on device, write'));
- const sdkNotSaved = sinon.stub(SdkGeneratePrompts.prototype, 'sdkNotSaved');
- const sdkGenerated = sinon.stub(SdkGeneratePrompts.prototype, 'sdkGenerated');
-
- expect((await execute(true)).isFailed()).to.be.true;
- expect(sdkNotSaved.firstCall.args[0]).to.deep.equal({
- kind: 'zipFailed',
- reason: 'ENOSPC: no space left on device, write',
- sdkDirectory: sdkDirectory.join('python')
+ describe('the overwrite question', () => {
+ beforeEach(() => {
+ fs.mkdirSync(sdk().toString(), { recursive: true });
+ fs.writeFileSync(at('README.md'), '# mine');
+ });
+
+ it('stops before generating when the developer declines', async () => {
+ const overwriteSdk = sinon.stub(SdkGeneratePrompts.prototype, 'overwriteSdk').resolves(false);
+ sinon.stub(SdkGeneratePrompts.prototype, 'destinationDirNotEmpty');
+
+ expect((await execute({ force: false, canAsk: true })).isCancelled()).to.be.true;
+
+ expect(overwriteSdk.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(generateSdk.called).to.be.false;
+ expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# mine');
+ });
+
+ it('goes ahead on yes', async () => {
+ sinon.stub(SdkGeneratePrompts.prototype, 'overwriteSdk').resolves(true);
+
+ expect((await execute({ force: false, canAsk: true })).isSuccess()).to.be.true;
+
+ expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# sdk');
+ });
+
+ it('is not asked where nobody can answer it: the run fails, naming --force', async () => {
+ const overwriteSdk = sinon.stub(SdkGeneratePrompts.prototype, 'overwriteSdk');
+ const cannotAsk = sinon.stub(SdkGeneratePrompts.prototype, 'cannotAskToOverwrite');
+
+ expect((await execute({ force: false, canAsk: false })).isFailed()).to.be.true;
+
+ expect(overwriteSdk.called).to.be.false;
+ expect(cannotAsk.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(generateSdk.called).to.be.false;
+ expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# mine');
+ });
+
+ it('is skipped by --force', async () => {
+ const overwriteSdk = sinon.stub(SdkGeneratePrompts.prototype, 'overwriteSdk');
+
+ expect((await execute({ force: true, canAsk: true })).isSuccess()).to.be.true;
+
+ expect(overwriteSdk.called).to.be.false;
+ expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# sdk');
});
- expect(sdkGenerated.called).to.be.false;
});
});
diff --git a/test/actions/sdk/publish.test.ts b/test/actions/sdk/publish.test.ts
index aec005420..2f9b7e97e 100644
--- a/test/actions/sdk/publish.test.ts
+++ b/test/actions/sdk/publish.test.ts
@@ -6,22 +6,26 @@ import { expect } from 'chai';
import { err, ok } from 'neverthrow';
import sinon from 'sinon';
import { SdkPublishAction } from '../../../src/actions/sdk/publish.js';
+import { SdkPublishNonInteractiveAction } from '../../../src/actions/sdk/publish/non-interactive.js';
+import { LauncherService } from '../../../src/infrastructure/launcher-service.js';
import { PublishingApiService } from '../../../src/infrastructure/services/publishing-api-service.js';
import { SdkGenerationService } from '../../../src/infrastructure/services/sdk-generation-service.js';
import { ZipService } from '../../../src/infrastructure/zip-service.js';
import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js';
import { SdkPublishPrompts } from '../../../src/prompts/sdk/publish.js';
+import { SdkPublishNonInteractivePrompts } from '../../../src/prompts/sdk/publish/non-interactive.js';
import { CommandMetadata } from '../../../src/types/common/command-metadata.js';
import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
import { FileName } from '../../../src/types/file/fileName.js';
import { FilePath } from '../../../src/types/file/filePath.js';
import { FileProblem } from '../../../src/types/file/file-problem.js';
import { ProjectContext } from '../../../src/types/project-context.js';
-import { PublishType } from '../../../src/types/publish-api/publishing-profile-item.js';
+import { PublishingProfileItem, PublishType } from '../../../src/types/publish-api/publishing-profile-item.js';
import { ProfileId } from '../../../src/types/publish/profile-id.js';
import { PublishingProfile } from '../../../src/types/publish/publishing-profile.js';
import { SemVersion } from '../../../src/types/publish/version.js';
import { Language, Stability } from '../../../src/types/sdk/generate.js';
+import { StabilityChoice } from '../../../src/types/sdk/stability-choice.js';
import { TempContext } from '../../../src/types/temp-context.js';
const COMMAND_METADATA: CommandMetadata = { commandName: 'sdk publish', shell: 'test' };
@@ -34,18 +38,21 @@ describe('SdkPublishAction', () => {
getPackageConfigurationDataForLanguage: () => undefined
} as unknown as PublishingProfile;
- const execute = () =>
+ const sdk = () => new DirectoryPath(path.join(root, 'sdk', 'python'));
+
+ const execute = (dryRun = false) =>
new SdkPublishAction(new DirectoryPath(root), COMMAND_METADATA).execute(
ProjectContext.in(new DirectoryPath(root)),
- new DirectoryPath(path.join(root, 'sdk')),
+ sdk(),
Language.PYTHON,
+ Stability.STABLE,
[PublishType.PackagePublishing],
true,
+ false,
ProfileId.tryCreate('profile')._unsafeUnwrap(),
SemVersion.tryCreate('1.0.0')._unsafeUnwrap(),
withoutPackageSettings,
- false,
- Stability.STABLE,
+ dryRun,
'summary',
onPublishSdkError
);
@@ -72,6 +79,67 @@ describe('SdkPublishAction', () => {
fs.rmSync(root, { recursive: true, force: true });
});
+ it('generates into the SDK directory on a dry run and opens it, uploading nothing', async () => {
+ sinon.stub(SdkGeneratePrompts.prototype, 'sdkGenerated');
+ const dryRunNotice = sinon.stub(SdkPublishPrompts.prototype, 'dryRunNotice');
+ const opened = sinon.stub(LauncherService.prototype, 'openDirectoryInEditorOrFileExplorer').resolves();
+ const publishSdkPackage = sinon.stub(PublishingApiService.prototype, 'publishSdkPackage');
+
+ expect((await execute(true)).isSuccess()).to.be.true;
+
+ expect(fs.readFileSync(path.join(sdk().toString(), 'README.md'), 'utf8')).to.equal('# sdk');
+ expect(opened.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(dryRunNotice.calledOnce).to.be.true;
+ expect(publishSdkPackage.called).to.be.false;
+ });
+
+ it('runs a dry run on the SDK directory the project names, not a throwaway one', async () => {
+ const profile = {
+ id: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6',
+ name: 'profile',
+ apiGroupId: 'group',
+ apiGroupName: 'Group',
+ cSharpConfiguration: null,
+ goConfiguration: null,
+ javaConfiguration: null,
+ phpConfiguration: null,
+ pythonConfiguration: null,
+ rubyConfiguration: null,
+ typeScriptConfiguration: null,
+ cSharpGitConfiguration: null,
+ goGitConfiguration: null,
+ javaGitConfiguration: null,
+ phpGitConfiguration: null,
+ pythonGitConfiguration: { isEnabled: true, credentialsId: 'c', repositoryName: 'sdk', branch: 'main' },
+ rubyGitConfiguration: null,
+ typeScriptGitConfiguration: null
+ } satisfies PublishingProfileItem;
+ sinon.stub(PublishingApiService.prototype, 'getPublishingProfiles').resolves(ok([profile]));
+ sinon.stub(SdkPublishNonInteractivePrompts.prototype, 'getPublishingProfiles').callsFake((fn) => fn);
+ sinon.stub(SdkPublishNonInteractivePrompts.prototype, 'sourceCodeOnlyPublishingNotice');
+ sinon.stub(SdkPublishNonInteractivePrompts.prototype, 'dryRunPluginConfigNotice');
+ sinon.stub(SdkGeneratePrompts.prototype, 'sdkGenerated');
+ sinon.stub(SdkPublishPrompts.prototype, 'dryRunNotice');
+ const opened = sinon.stub(LauncherService.prototype, 'openDirectoryInEditorOrFileExplorer').resolves();
+
+ const result = await new SdkPublishNonInteractiveAction(new DirectoryPath(root), COMMAND_METADATA).execute(
+ ProjectContext.in(new DirectoryPath(root)),
+ undefined,
+ Language.PYTHON,
+ [PublishType.SourceCodePublishing],
+ true,
+ true,
+ StabilityChoice.for(Language.PYTHON, undefined),
+ onPublishSdkError,
+ profile.id,
+ '1.0.0'
+ );
+
+ expect(result.isSuccess()).to.be.true;
+ expect(fs.readFileSync(path.join(sdk().toString(), 'README.md'), 'utf8')).to.equal('# sdk');
+ expect(opened.firstCall.args[0].isEqual(sdk())).to.be.true;
+ });
+
it('reports an SDK it could not zip, without publishing', async () => {
const problem: FileProblem = { kind: 'zipFailed', reason: 'EACCES: permission denied' };
const zip = sinon.stub(TempContext.prototype, 'zip').callThrough();
diff --git a/test/prompts/sdk/generate.test.ts b/test/prompts/sdk/generate.test.ts
index 66092fa12..f706c76ce 100644
--- a/test/prompts/sdk/generate.test.ts
+++ b/test/prompts/sdk/generate.test.ts
@@ -1,4 +1,3 @@
-import path from 'node:path';
import { stripVTControlCharacters } from 'node:util';
import { expect } from 'chai';
import sinon from 'sinon';
@@ -6,7 +5,6 @@ import { log } from '@clack/prompts';
import { ServiceError } from '../../../src/infrastructure/service-error.js';
import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js';
import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
-import { VersionProblem } from '../../../src/types/project-context.js';
import { Language } from '../../../src/types/sdk/generate.js';
describe('SdkGeneratePrompts.languageNotAvailable', () => {
@@ -55,32 +53,31 @@ describe('SdkGeneratePrompts.languageNotAvailable', () => {
});
});
-describe('SdkGeneratePrompts.noVersionToBuild', () => {
- const prompts = new SdkGeneratePrompts();
- const sourceDirectory = new DirectoryPath(path.resolve('project', 'src'));
- let error: sinon.SinonStub;
-
- beforeEach(() => {
- error = sinon.stub(log, 'error');
- });
+describe('SdkGeneratePrompts, about the destination', () => {
+ const root = new DirectoryPath(process.cwd());
afterEach(() => {
- error.restore();
+ sinon.restore();
});
- const messageFor = (problem: VersionProblem): string => {
- prompts.noVersionToBuild(problem, sourceDirectory);
- return stripVTControlCharacters(error.firstCall.args[0] as string);
- };
+ it('refuses a destination holding the source, and suggests one outside it', () => {
+ const error = sinon.stub(log, 'error');
- it('names the source directory when it holds no versions', () => {
- expect(messageFor('noVersions')).to.equal(
- `The 'versioned_docs' directory is either empty or invalid: '${sourceDirectory}'`
- );
+ new SdkGeneratePrompts().destinationContainsSource(root.join('src'), root);
+
+ const said = stripVTControlCharacters(error.firstCall.args[0]);
+ expect(said).to.contain('contains your source directory');
+ expect(said).to.contain('--destination=./sdk');
});
- it('says the chosen version is not one of them', () => {
- expect(messageFor('versionNotFound')).to.equal('The selected API version is invalid.');
+ it('sends a run that cannot ask about overwriting to a terminal, or to --force', () => {
+ const error = sinon.stub(log, 'error');
+
+ new SdkGeneratePrompts().cannotAskToOverwrite(root.join('sdk', 'python'));
+
+ const said = stripVTControlCharacters(error.firstCall.args[0]);
+ expect(said).to.contain('cannot ask whether to overwrite it');
+ expect(said).to.contain('Rerun in a terminal to be asked, or with --force to overwrite.');
});
});
diff --git a/test/types/project-context.test.ts b/test/types/project-context.test.ts
index 2cc05f452..8b3b87f05 100644
--- a/test/types/project-context.test.ts
+++ b/test/types/project-context.test.ts
@@ -19,9 +19,6 @@ describe('ProjectContext', () => {
fs.writeFileSync(inProject(relative), contents);
};
- const versionedBuild = (build: object = { generateVersionedPortal: {} }) =>
- write('src/APIMATIC-BUILD.json', JSON.stringify(build));
-
beforeEach(() => {
projectDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'project-context-'));
});
@@ -37,13 +34,15 @@ describe('ProjectContext', () => {
});
it('writes each output beside the source directory', () => {
- expect(project().sdkDirectory().toString()).to.equal(inProject('sdk'));
+ expect(project().sdkDirectory(Language.PYTHON).toString()).to.equal(inProject('sdk', 'python'));
expect(project().portalDirectory().toString()).to.equal(inProject('portal'));
expect(project().pluginDirectory().toString()).to.equal(inProject('plugin'));
});
it('writes where the destination flag says instead, when it says anywhere', () => {
- expect(project().sdkDirectory('elsewhere').toString()).to.equal(path.resolve('elsewhere'));
+ expect(project().sdkDirectory(Language.PYTHON, new DirectoryPath('elsewhere')).toString()).to.equal(
+ path.resolve('elsewhere')
+ );
expect(project().portalDirectory('').toString()).to.equal(inProject('portal'));
});
@@ -180,98 +179,11 @@ describe('ProjectContext', () => {
});
});
- describe('versionToBuild', () => {
- const ask = () => sinon.stub<[string[]], Promise>();
-
- it('builds from the project itself when it has no build file', async () => {
- const whole = project();
-
- expect((await whole.versionToBuild(undefined, ask()))._unsafeUnwrap()).to.equal(whole);
- expect(await whole.isVersioned()).to.be.false;
- });
-
- it('builds from the project itself when its build file declares no versions', async () => {
- versionedBuild({});
- const whole = project();
-
- expect((await whole.versionToBuild('v2', ask()))._unsafeUnwrap()).to.equal(whole);
- });
-
- it('has nothing to build from when a versioned build holds no versions', async () => {
- versionedBuild();
- expect(await project().isVersioned()).to.be.true;
- expect((await project().versionToBuild(undefined, ask()))._unsafeUnwrapErr()).to.equal('noVersions');
-
- fs.mkdirSync(inProject('src', 'versioned_docs'));
-
- expect((await project().versionToBuild(undefined, ask()))._unsafeUnwrapErr()).to.equal('noVersions');
- });
-
- it('reads the only version there is without asking', async () => {
- versionedBuild();
- fs.mkdirSync(inProject('src', 'versioned_docs', 'v1'), { recursive: true });
- const asked = ask();
-
- const version = (await project().versionToBuild(undefined, asked))._unsafeUnwrap();
-
- expect(version.sourceDirectory().toString()).to.equal(inProject('src', 'versioned_docs', 'v1'));
- expect(asked.called).to.be.false;
- });
-
- it('asks which version when there are several, offering each by name', async () => {
- versionedBuild();
- fs.mkdirSync(inProject('src', 'versioned_docs', 'v1'), { recursive: true });
- fs.mkdirSync(inProject('src', 'versioned_docs', 'v2'), { recursive: true });
- const asked = ask().resolves('v2');
-
- const version = (await project().versionToBuild(undefined, asked))._unsafeUnwrap();
-
- expect(asked.firstCall.args[0]).to.have.members(['v1', 'v2']);
- expect(version.sourceDirectory().toString()).to.equal(inProject('src', 'versioned_docs', 'v2'));
- });
-
- it('reads the version the flag names without asking, even when it is the only one', async () => {
- versionedBuild({ generateVersionedPortal: {}, versionsPath: 'versions' });
- fs.mkdirSync(inProject('src', 'versions', 'v3'), { recursive: true });
- const asked = ask();
-
- const version = (await project().versionToBuild('v3', asked))._unsafeUnwrap();
-
- expect(version.sourceDirectory().toString()).to.equal(inProject('src', 'versions', 'v3'));
- expect(asked.called).to.be.false;
- });
-
- it('has no version to build when the flag or the answer names none there is', async () => {
- versionedBuild();
- fs.mkdirSync(inProject('src', 'versioned_docs', 'v1'), { recursive: true });
- fs.mkdirSync(inProject('src', 'versioned_docs', 'v2'), { recursive: true });
-
- expect((await project().versionToBuild('v9', ask()))._unsafeUnwrapErr()).to.equal('versionNotFound');
- expect((await project().versionToBuild(undefined, ask().resolves(undefined)))._unsafeUnwrapErr()).to.equal(
- 'versionNotFound'
- );
- });
-
- it('writes a version where the project writes, the SDK under the version it was built from', async () => {
- versionedBuild();
- fs.mkdirSync(inProject('src', 'versioned_docs', 'v1'), { recursive: true });
- const empty = new DirectoryPath(inProject('empty'));
- fs.mkdirSync(empty.toString());
-
- const version = (await project().versionToBuild(undefined, ask()))._unsafeUnwrap();
- const saved = (await version.sdk(Language.TYPESCRIPT, version.sdkDirectory()).save(empty, false))._unsafeUnwrap();
-
- expect(version.sdkDirectory().toString()).to.equal(inProject('sdk'));
- expect(saved.toString()).to.equal(inProject('sdk', 'v1', 'typescript'));
- });
-
- it('writes an unversioned SDK straight under the SDK directory', async () => {
- const empty = new DirectoryPath(inProject('empty'));
- fs.mkdirSync(empty.toString());
-
- const saved = (await project().sdk(Language.PYTHON, project().sdkDirectory()).save(empty, false))._unsafeUnwrap();
+ describe('sdk', () => {
+ it('writes into a named destination itself, adding no language', () => {
+ const clone = new DirectoryPath(inProject('my-sdk'));
- expect(saved.toString()).to.equal(inProject('sdk', 'python'));
+ expect(project().sdkDirectory(Language.TYPESCRIPT, clone).isEqual(clone)).to.be.true;
});
});
diff --git a/test/types/sdk-context.test.ts b/test/types/sdk-context.test.ts
index fe98f11c3..f9eb119b3 100644
--- a/test/types/sdk-context.test.ts
+++ b/test/types/sdk-context.test.ts
@@ -3,10 +3,7 @@ import os from 'os';
import path from 'path';
import { expect } from 'chai';
import { DirectoryPath } from '../../src/types/file/directoryPath.js';
-import { FileName } from '../../src/types/file/fileName.js';
-import { FilePath } from '../../src/types/file/filePath.js';
import { SdkContext } from '../../src/types/sdk-context.js';
-import { Language } from '../../src/types/sdk/generate.js';
describe('SdkContext', () => {
let root: string;
@@ -16,43 +13,34 @@ describe('SdkContext', () => {
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-context-'));
- context = new SdkContext(Language.PYTHON, new DirectoryPath(inRoot('sdk')));
+ context = new SdkContext(new DirectoryPath(inRoot('sdk', 'python')));
});
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
- describe('save', () => {
- it('zips the SDK into its language directory', async () => {
- fs.mkdirSync(inRoot('generated'));
- fs.writeFileSync(inRoot('generated', 'README.md'), '# sdk');
+ describe('exists', () => {
+ it('is whether the directory holds anything, so a missing or empty one is written without a question', async () => {
+ expect(await context.exists()).to.be.false;
- const saved = await context.save(new DirectoryPath(inRoot('generated')), true);
+ fs.mkdirSync(inRoot('sdk', 'python'), { recursive: true });
+ expect(await context.exists()).to.be.false;
- expect(saved._unsafeUnwrap().toString()).to.equal(inRoot('sdk', 'python'));
- expect(fs.existsSync(inRoot('sdk', 'python', 'python.zip'))).to.be.true;
- });
-
- it('reports an SDK it could not zip, naming its language directory, and leaves no archive behind', async () => {
- const saved = await context.save(new DirectoryPath(inRoot('missing')), true);
-
- const problem = saved._unsafeUnwrapErr();
- expect(problem.kind).to.equal('zipFailed');
- expect(problem.reason).to.contain('ENOENT');
- expect(problem.sdkDirectory.toString()).to.equal(inRoot('sdk', 'python'));
- expect(fs.existsSync(inRoot('sdk', 'python', 'python.zip'))).to.be.false;
+ fs.writeFileSync(inRoot('sdk', 'python', 'README.md'), '# mine');
+ expect(await context.exists()).to.be.true;
});
});
- describe('loadSdkInTempDirectory', () => {
- it('reports a download that is not an archive', async () => {
- const download = new FilePath(new DirectoryPath(root), new FileName('sdk.zip'));
- fs.writeFileSync(download.toString(), 'not a zip');
+ describe('save', () => {
+ it('replaces what the SDK directory holds with the generation, and answers with the directory', async () => {
+ fs.mkdirSync(inRoot('generated'));
+ fs.writeFileSync(inRoot('generated', 'README.md'), '# sdk');
+ fs.mkdirSync(inRoot('sdk', 'python'), { recursive: true });
+ fs.writeFileSync(inRoot('sdk', 'python', 'old.py'), 'old');
- const loaded = await context.loadSdkInTempDirectory(new DirectoryPath(inRoot('temp')), download);
+ const saved = await context.save(new DirectoryPath(inRoot('generated')));
- const problem = loaded._unsafeUnwrapErr();
- expect(problem.kind).to.equal('unzipFailed');
- expect(problem.reason).to.not.be.empty;
+ expect(saved.toString()).to.equal(inRoot('sdk', 'python'));
+ expect(fs.readdirSync(inRoot('sdk', 'python'))).to.deep.equal(['README.md']);
});
});
});
diff --git a/test/types/temp-context.test.ts b/test/types/temp-context.test.ts
new file mode 100644
index 000000000..2b57e1fe7
--- /dev/null
+++ b/test/types/temp-context.test.ts
@@ -0,0 +1,47 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { expect } from 'chai';
+import { ZipService } from '../../src/infrastructure/zip-service.js';
+import { DirectoryPath } from '../../src/types/file/directoryPath.js';
+import { FileName } from '../../src/types/file/fileName.js';
+import { FilePath } from '../../src/types/file/filePath.js';
+import { TempContext } from '../../src/types/temp-context.js';
+
+describe('TempContext', () => {
+ let root: string;
+
+ const inRoot = (...segments: string[]) => path.join(root, ...segments);
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'temp-context-'));
+ fs.mkdirSync(inRoot('temp'));
+ });
+
+ afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
+
+ describe('unzip', () => {
+ it('expands an archive into a directory of its own below the temp directory', async () => {
+ fs.mkdirSync(inRoot('generated'));
+ fs.writeFileSync(inRoot('generated', 'README.md'), '# sdk');
+ const archive = new FilePath(new DirectoryPath(inRoot('temp')), new FileName('sdk.zip'));
+ (await new ZipService().archive(new DirectoryPath(inRoot('generated')), archive))._unsafeUnwrap();
+
+ const unzipped = (await new TempContext(new DirectoryPath(inRoot('temp'))).unzip(archive))._unsafeUnwrap();
+
+ expect(new DirectoryPath(inRoot('temp')).isAncestorOf(unzipped)).to.be.true;
+ expect(fs.readFileSync(path.join(unzipped.toString(), 'README.md'), 'utf8')).to.equal('# sdk');
+ });
+
+ it('reports a download that is not an archive', async () => {
+ const download = new FilePath(new DirectoryPath(root), new FileName('sdk.zip'));
+ fs.writeFileSync(download.toString(), 'not a zip');
+
+ const unzipped = await new TempContext(new DirectoryPath(inRoot('temp'))).unzip(download);
+
+ const problem = unzipped._unsafeUnwrapErr();
+ expect(problem.kind).to.equal('unzipFailed');
+ expect(problem.reason).to.not.be.empty;
+ });
+ });
+});
From bc5166361a0bd605a864f93ad7cc32f600e24614 Mon Sep 17 00:00:00 2001
From: Asad Ali <14asadali@gmail.com>
Date: Mon, 2 Nov 2026 16:58:46 +0500
Subject: [PATCH 02/28] feat(sdk): record the first generation under git with
--git-init
`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
---
.ai/plans/custom-code-phases.md | 146 ++++++-----
README.md | 4 +-
src/actions/sdk/generate.ts | 42 ++-
src/actions/sdk/publish.ts | 2 +
src/commands/sdk/generate.ts | 10 +-
src/infrastructure/git-service.ts | 159 ++++++++++++
src/prompts/sdk/generate.ts | 56 +++-
src/types/git-context.ts | 76 ++++++
src/types/git/commit-id.ts | 15 ++
src/types/git/git-version.ts | 43 ++++
src/types/git/object-id.ts | 4 +
src/types/git/tree-id.ts | 19 ++
test/actions/sdk/generate-destination.test.ts | 241 ++++++++++++++++++
test/actions/sdk/generate.test.ts | 64 ++---
test/helpers/sdk-generation.ts | 50 ++++
test/helpers/snapshots.ts | 31 +++
test/infrastructure/git-service.test.ts | 146 +++++++++++
test/prompts/sdk/generate.test.ts | 93 +++++++
test/types/git-context.test.ts | 197 ++++++++++++++
test/types/git/git-version.test.ts | 23 ++
test/types/git/object-id.test.ts | 17 ++
21 files changed, 1332 insertions(+), 106 deletions(-)
create mode 100644 src/infrastructure/git-service.ts
create mode 100644 src/types/git-context.ts
create mode 100644 src/types/git/commit-id.ts
create mode 100644 src/types/git/git-version.ts
create mode 100644 src/types/git/object-id.ts
create mode 100644 src/types/git/tree-id.ts
create mode 100644 test/actions/sdk/generate-destination.test.ts
create mode 100644 test/helpers/sdk-generation.ts
create mode 100644 test/helpers/snapshots.ts
create mode 100644 test/infrastructure/git-service.test.ts
create mode 100644 test/types/git-context.test.ts
create mode 100644 test/types/git/git-version.test.ts
create mode 100644 test/types/git/object-id.test.ts
diff --git a/.ai/plans/custom-code-phases.md b/.ai/plans/custom-code-phases.md
index aaefa5ad8..6ab3acfe5 100644
--- a/.ai/plans/custom-code-phases.md
+++ b/.ai/plans/custom-code-phases.md
@@ -93,45 +93,53 @@ developer declines to overwrite"; `project-context.test.ts` "sdk" cases; `publis
### Phase 1: the first run records
With `--git-init`, the SDK directory becomes a repository of its own and the generation is
-recorded in it, so the next run has a base.
+recorded in it under the pristine ref, so the next run has a base.
Behaviour:
1. Git is looked for only when the SDK directory has a `.git` of its own, a folder or a
worktree's file, or `--git-init` was given. Otherwise the SDK is saved as in phase 0, and the
run ends with the Next steps box: rerun with `--git-init` before changing anything.
-2. Git absent, or older than 2.38: warn that customizations will not persist, then save as
- before, keeping any `.git`. Git present but not answering: Failed.
+2. Git absent or not answering: warn that it is not available. Older than 2.38: warn, naming the
+ version found. Either way the SDK is saved as in phase 0, `.git` included in what the save
+ removes, and no next steps are given.
3. A repository counts only when its root is the SDK directory itself. One that only encloses
- it is treated as none, and nothing is written into it.
-4. `--git-init` on a directory with no repository of its own: `git init` on `main` after the
- generation succeeded, say so, save, record. A failed init writes nothing.
-5. A repository of its own with no record: say it was found, save, record.
-6. Recording: the generation as a tree of every file, committed under `refs/apimatic/pristine`
- with the CLI as author; then `.apimatic/.gitattributes` and `.apimatic/generation.json`
- naming the commit, each written only when changed. A failure here is a warning, since the
- SDK is on disk.
-7. The overwrite question counts every entry but `.git`, hidden ones included, since the save
- keeps only `.git`.
-
-Touches: `GitService` with its runner (the `-c` settings, the attributes file for normalised
-line endings, `GIT_DIR` cleared) and the methods `version`, `topLevel`, `init`, `tree` of every
-file, `commitTree`, `resolveRef`, `treeOfCommit`, `updateRef`; `GitVersion`, `CommitId`,
-`TreeId`, `SnapshotRef`, `GenerationRecord`, `GIT_DIRECTORY`; `FileService.hasEntriesExcluding`;
-the `--git-init` flag; the prompts for the warning, the two repository messages, the Next steps,
-the failed init, a failed recording and a directory git could not inspect; the ESLint rule that
-keeps `src/types/sdk/` free of file and process access.
-
-Tests from `92988f2f`: generate-destination "with no git / an older git …" (both), "does not
-look for git without a repository …", the "not a repository of its own" group, "records into
-the SDK directory's own repository …", "asks before replacing a repository … hidden files",
-the "overwrite question" group, "reports a directory git could not inspect"; generate-write
-"overwrite" and "record" groups; sdk-context "destination, by the SDK directory's own
-repository", "destination, and when git is looked for", "exists", "initRepository", "save";
-sdk-repository-context "recordPristine" group and "the generation record" (first three);
-git-service "treeOf" (every file, CRLF stored as LF), `version`, `topLevel`, `init`,
-`commitTree`, `resolveRef`; the prompts tests for the messages added; `generation-record`,
-`snapshot-ref`, `git-version`, `commit-id` value-object tests.
+ it is treated as none, and nothing is written into it. Git that will not say what the
+ repository is: Failed, before generating.
+4. `--git-init`, or a repository of its own: after the save, `git init` on `main` in the
+ directory, say so, record. The save removes any `.git`, so a found repository is made afresh;
+ "Found a git repository" and a save that keeps `.git` come with phase 2. A failed init is a
+ warning: the SDK is written, nothing recorded.
+5. Recording: the generation as a tree of every file, committed under `refs/apimatic/pristine`
+ with the CLI as author and chained onto the last snapshot; an identical generation keeps the
+ last commit. A failure here is a warning, since the SDK is on disk. Nothing else is written:
+ the record file arrives with the phase that first reads it.
+
+Touches: `GitService` with its runner (git's messages in English, nothing else set) and the
+methods `version` (a `GitVersion`, none for no git or one that does not answer), `topLevel`,
+`init` (git creates the directory and its parents), `treeOf` every file through a temporary
+index, `commitTree`, `resolveRef`, `treeOfCommit`, `updateRef`, each failing with git's words as
+a string, which the prompts take as `reason`; `GitVersion` total, with `none`, `isFound` and
+`isSupported`; `CommitId`, `TreeId`; `GitContext` on the SDK directory: `hasGitDirectory` (a
+`.git` of its own, read without git), `isRepository(git)` (as that git sees it; false, with no
+git run, without a `.git` or a git that can serve), `recordSnapshot`, with the pristine ref a constant of the module;
+`SdkContext` as in phase 0; no run flag reaches a context, and `execute` combines the facts: the
+version is read only with a `.git` of its own or `--git-init`, and the run records only with a
+usable git; the `--git-init` flag; the prompts for the two warnings, the initialised message, the
+Next steps, the failed init, a failed recording, and a repository git would not inspect.
+
+Left for the phase that first reads them: `.apimatic/generation.json` (phase 2) and its
+attributes (phase 5), trees with line endings normalised and the runner's `-c` settings
+(phase 2), `GIT_DIR` cleared for a run from a hook (phase 6), the ESLint rule for the pure
+modules (phase 8).
+
+Tests: `generate-destination.test.ts` (git absent or old, not looked for, the repository cases,
+the recording and its failures); `sdk-context.test.ts` as in phase 0; `git-context.test.ts`
+("hasGitDirectory", "isRepository", "recordSnapshot"); `git-service.test.ts` (`version`,
+`topLevel`, `init`, `treeOf`, the commit, ref and tree round trip); the prompts tests for the
+messages added; `git-version` (none included) and `object-id` value-object tests. Helpers:
+`test/helpers/sdk-generation.ts` (a stubbed generation) and `test/helpers/snapshots.ts` (a
+commit described and its files, through git itself).
### Phase 2: a regeneration merges
@@ -146,6 +154,12 @@ Behaviour:
question, with no further warning. The commit missing and no remote: say the snapshot is
not available, then record afresh. Under `--force`, a recorded SDK the run cannot merge into
is replaced, with a warning after the write that the customizations were not carried over.
+ "Found a git repository" is said only by the first run to record in it, the one that finds
+ no record; a later run, merging or recording afresh, no longer says it.
+ A run that merges does not ask to overwrite, since it writes only what changed; the question
+ stays for every save. The merge needs `.git` to survive the write, so the save that keeps it
+ and the check that counts every entry but `.git` (a `hasFilesOtherThanMetadata`, name open)
+ may move to `GitContext`; if nothing then reads `SdkContext`, it is deleted.
2. Snapshot the generation (every file) and the directory as it is (its ignore rules honoured,
except that every path the base holds goes in), both with line endings normalised.
3. Merge the two over the base with `merge-tree`, renames paired both ways. From git's merge
@@ -158,13 +172,17 @@ Behaviour:
6. A write that fails puts back what it touched from the previous snapshot and names the
file; if that fails too, the restore command is given. Failed.
7. Record the new generation chained on the base; an identical generation keeps the last
- commit. Then the summary: counts of added, updated, merged and removed files, and the
- merged and kept files by name.
+ commit, and `.apimatic/generation.json` names it, written only when changed. Then the
+ summary: counts of added, updated, merged and removed files, and the merged and kept files
+ by name.
8. With conflicts: list them with their kinds and how to rerun; nothing written. Failed.
-Touches: `GitService.hasCommit`, `listTree`, `tree` of the working tree (normalised and as
-is), `readBlobs`, `mergeTree`, `diff`; `BlobId`, `RelativeFilePath`, `ValueMap`, the
-`merge-tree` and `diff-tree` output types and parsers; `FileService.getBytes`, `writeBytes`,
+Touches: `GenerationRecord` and the record file, read by the inspection and written after every
+recording; `SnapshotRef` with the previous ref; the runner's `-c` settings and the attributes
+file that store trees with line endings normalised; `GitService.hasCommit`, `listTree`, `tree`
+of the working tree (normalised and as is), `readBlobs`, `mergeTree`, `diff`; `BlobId`,
+`RelativeFilePath`, `ValueMap`, the `merge-tree` and `diff-tree` output types and parsers;
+`FileService.getBytes`, `writeBytes`,
`removeEmptyDirectories`; the merge plan and the summary as pure data; binary and line-ending
rules; the `recordedSdkReplaced`, `snapshotMissing` (no remote), `sdkMerged`,
`conflictsNotAsked`, `sdkNotWritten` and `customCodeFailed` prompts.
@@ -179,7 +197,10 @@ plan lands on disk", "a write that fails partway", the content rules except "lab
markers …" and the binary conflict, "two spec versions" for the files they keep);
sdk-repository-context "inspect" cases for no record, another generation and a missing
snapshot; git-service `mergeTree`, `diff`, `listTree`, `readBlobs`, "keeps every byte …";
-`merge-plan.test.ts` for writes, deletes and the summary; `file-content` rules.
+`merge-plan.test.ts` for writes, deletes and the summary; `file-content` rules;
+`generation-record` and `snapshot-ref` value-object tests; sdk-repository-context "the
+generation record" (written beside the snapshot, absent when it cannot be read); git-service
+"stores a CRLF file with LF …".
### Phase 3: conflicts are asked about
@@ -255,13 +276,15 @@ Behaviour:
Touches: `GitService.remoteOf`, `fetchRef`, `isAncestor`, the remote options of the runner;
`RemoteName`; the chain reconciliation as a pure rule; `snapshotMissing` for the absent and
-failed cases.
+failed cases; `.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.
Tests from `92988f2f`: generate-destination "merges in a clone only once the pristine refs
were fetched by hand …", "merges over a clone of an SDK repository named as the destination
…"; sdk-repository-context "sharing" group; git-service "remotes" group except the hooks
cases, "leaves an SSH client the developer configured …", "tells an ancestor from a stranger";
-`pristine-chain.test.ts`.
+`pristine-chain.test.ts`; sdk-repository-context "is written with attributes that merge it as
+binary …" and "stays yours … when a pull meets a teammate's record".
### Phase 6: every push shares the snapshots
@@ -280,7 +303,9 @@ Behaviour:
4. The run says once that it wrote the hook, or what one step is still needed, or that the hook
could not be written. Later runs are silent.
-Touches: `GitService.hooksDirectory`, `hooksPathConfigured`, `hooksRunFrom`;
+Touches: `GIT_DIR`, `GIT_WORK_TREE` and `GIT_INDEX_FILE` cleared from the environment of every
+git call, since a hook runs the CLI with its own repository in the environment;
+`GitService.hooksDirectory`, `hooksPathConfigured`, `hooksRunFrom`;
`FileService.makeExecutable`; the script and the marked block as constants; the hook decision
as a pure rule; the `shareHookInstalled`, `shareHookNeedsOneStep` and `shareHookNotInstalled`
prompts, and the recording notice that chooses one message.
@@ -320,8 +345,9 @@ Tests from `92988f2f`: `publish.test.ts` ("uploads the SDK without the CLI's rec
code.
2. The design document `.ai/plans/custom-code.md` from `533bcf1a` comes back with sections 9 and
10 rewritten for the layout the phases produced; the decisions and the lab cases stay.
-3. The README is regenerated, and the convention notes land in the `.ai` instructions and
- skills.
+3. The README is regenerated, the convention notes land in the `.ai` instructions and
+ skills, and an ESLint rule keeps the pure modules under `src/types/git/` free of file and
+ process access, if wanted.
4. Optional, to decide at review: the cleanups the first attempt carried that are not this
feature. The plugin action and the not-found hooks reading the terminal check instead of
`process.stdin.isTTY`; the unused methods on the config, plugin, portal, spec and file
@@ -339,25 +365,21 @@ time saves the question.
type for three flags.
- Separate parameters. For: nothing new. Against: every caller changes with every phase.
-**Phase 1: where the inspection lives.** Reading the `.git` on disk, asking git for its
-version and the root, then the record.
-
-- On `SdkContext`, which already holds the directory. For: the directory is the only state
- these steps need. Against: the class grows a method for each phase.
-- Module-level functions taking the directory, with no class. For: nothing to hold. Against:
- the project's convention is a context class around a directory.
-
-**Phase 1: the repository as a fact.** Once git says the directory is a repository of its own,
-the recording needs that root.
-
-- A second class holding the root, created only when the root is found. For: "a repository was
- found" is carried by the type, checked once; every later phase adds to it (inspect, merge,
- settle, record, hook). Against: two classes around one directory; the first phase would give
- it two methods.
-- Methods on `SdkContext` that take the root they were handed. For: one class. Against: every
- caller passes the root the class could have held, which is the middleman shape in reverse.
-- Decide by the end of phase 2, when there are enough methods to see the shape. For: the
- evidence is in. Against: phase 1 is written one way and may be moved.
+**Phase 1: where the inspection lives.** Answered, after three reviews of the first cut:
+`GitContext` is git on the SDK directory: `hasGitDirectory` (disk), `isRepository(git)` (as the
+git it is handed sees it; false, with no git run, when there is no `.git` or that git cannot
+serve) and `recordSnapshot`; `GitService.version` answers a `GitVersion`, none for no git, and
+`execute` reads it only when the run needs git, holding `GitVersion.none()` otherwise, so every
+fact in the action is a `const` and no run flag reaches a context. A second cut had
+`repository()` fold the availability into its answer, which gave two types the same
+`gitUnavailable` case; a third had `availability` and `isRepository` side by side, with two
+`let`s in the action to carry what each branch learnt; handing the version to `isRepository`
+removed both.
+
+**Phase 1: the repository as a fact.** Answered for now: `GitContext` is built on the directory
+whether or not it is a repository, and `isRepository(git)` answers which; the root is the SDK
+directory itself. To revisit at the end of phase 2, when the merge, the record and the
+settlement show whether the seam between the two contexts holds.
**Phase 2: what a merge answers with.** Git's merge becomes a plan of writes, removals,
conflicts and the summary.
diff --git a/README.md b/README.md
index 93e94f623..fb27c9fb6 100644
--- a/README.md
+++ b/README.md
@@ -408,7 +408,7 @@ Generate an SDK for your API
```
USAGE
$ apimatic sdk generate -l csharp|java|php|python|ruby|typescript|go [-d ] [--codegen-version v4]
- [--stability stable|beta] [-i ] [-f] [-k ]
+ [--stability stable|beta] [--git-init] [-i ] [-f] [-k ]
FLAGS
-d, --destination= [default: /sdk/] directory the SDK is generated into
@@ -420,6 +420,8 @@ FLAGS
--codegen-version= [default: v4] Version of the code generator to use
+ --git-init make the SDK directory a git repository when it is not one of its own, so your edits
+ to the SDK are kept when you regenerate it
--stability= Stability level of the generated SDK. Defaults to the level the language offers.
diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts
index 585b819c0..948f98990 100644
--- a/src/actions/sdk/generate.ts
+++ b/src/actions/sdk/generate.ts
@@ -7,11 +7,15 @@ import { CommandMetadata } from '../../types/common/command-metadata.js';
import { TempContext } from '../../types/temp-context.js';
import { isAvailableLanguage, Language, Stability } from '../../types/sdk/generate.js';
import { ProjectContext } from '../../types/project-context.js';
+import { GitContext } from '../../types/git-context.js';
+import { GitVersion } from '../../types/git/git-version.js';
import { SdkContext } from '../../types/sdk-context.js';
+import { GitService } from '../../infrastructure/git-service.js';
export class GenerateAction {
private readonly prompts: SdkGeneratePrompts = new SdkGeneratePrompts();
private readonly sdkGenerationService: SdkGenerationService = new SdkGenerationService();
+ private readonly gitService: GitService = new GitService();
private readonly configDir: DirectoryPath;
private readonly commandMetadata: CommandMetadata;
private readonly authKey: string | null;
@@ -22,7 +26,10 @@ export class GenerateAction {
this.authKey = authKey;
}
- /** `canAsk` is whether a question may be put to the developer at all; `force` answers every question without asking. */
+ /**
+ * `canAsk` is whether a question may be put to the developer at all; `force` answers every question
+ * without asking. `gitInit` makes the SDK directory a repository of its own when it is not one.
+ */
public readonly execute = async (
project: ProjectContext,
sdk: DirectoryPath,
@@ -30,6 +37,7 @@ export class GenerateAction {
stability: Stability,
force: boolean,
canAsk: boolean,
+ gitInit: boolean,
packageSettingsDirectory?: DirectoryPath
): Promise => {
if (!isAvailableLanguage(language)) {
@@ -51,6 +59,22 @@ export class GenerateAction {
return ActionResult.failed();
}
+ const gitContext = new GitContext(sdk);
+ const needsGit = gitInit || (await gitContext.hasGitDirectory());
+ const git = needsGit ? await this.gitService.version() : GitVersion.none();
+ if (needsGit && !git.isFound()) {
+ this.prompts.gitUnavailable();
+ }
+ if (git.isFound() && !git.isSupported()) {
+ this.prompts.gitVersionNotSupported(git);
+ }
+ const repository = await gitContext.isRepository(git);
+ if (repository.isErr()) {
+ this.prompts.repositoryNotInspected(sdk, repository.error);
+ return ActionResult.failed();
+ }
+ const records = git.isSupported() && (gitInit || repository.value);
+
const sdkContext = new SdkContext(sdk);
if (!force && (await sdkContext.exists())) {
if (!canAsk) {
@@ -94,7 +118,23 @@ export class GenerateAction {
}
const saved = await sdkContext.save(generated.value);
+ if (records) {
+ const initialised = await this.gitService.init(saved);
+ if (initialised.isErr()) {
+ this.prompts.repositoryNotInitialised(saved, initialised.error);
+ } else {
+ this.prompts.repositoryInitialised(saved);
+ const recorded = await gitContext.recordSnapshot(generated.value);
+ if (recorded.isErr()) {
+ this.prompts.snapshotNotRecorded(recorded.error);
+ }
+ }
+ }
this.prompts.sdkGenerated(saved);
+ // No repository keeps edits, none was asked for, and git is not what is missing: the steps make one.
+ if (!records && (git.isSupported() || !needsGit)) {
+ this.prompts.gitInitSteps(saved);
+ }
return ActionResult.success();
});
};
diff --git a/src/actions/sdk/publish.ts b/src/actions/sdk/publish.ts
index 79f6430b3..6664d65d4 100644
--- a/src/actions/sdk/publish.ts
+++ b/src/actions/sdk/publish.ts
@@ -58,6 +58,8 @@ export class SdkPublishAction {
stability,
force,
canAsk,
+ // sdk publish does not make the SDK directory a repository.
+ false,
packageSettingsDirectory
);
if (sdkGenerationResult.isFailed()) {
diff --git a/src/commands/sdk/generate.ts b/src/commands/sdk/generate.ts
index f06439ebc..1eef6c235 100644
--- a/src/commands/sdk/generate.ts
+++ b/src/commands/sdk/generate.ts
@@ -49,6 +49,11 @@ ${inProse(AVAILABLE_LANGUAGES)} are available; ${inProse(UPCOMING_LANGUAGES)} ar
description: 'Stability level of the generated SDK. Defaults to the level the language offers.',
options: Object.values(Stability).map((s) => s.valueOf())
}),
+ 'git-init': Flags.boolean({
+ default: false,
+ description:
+ 'make the SDK directory a git repository when it is not one of its own, so your edits to the SDK are kept when you regenerate it'
+ }),
...FlagsProvider.input,
...FlagsProvider.force,
...FlagsProvider.authKey
@@ -62,7 +67,7 @@ ${inProse(AVAILABLE_LANGUAGES)} are available; ${inProse(UPCOMING_LANGUAGES)} ar
async run() {
const {
- flags: { language, input, destination, force, stability, 'auth-key': authKey }
+ flags: { language, input, destination, force, stability, 'auth-key': authKey, 'git-init': gitInit }
} = await this.parse(SdkGenerate);
const project = ProjectContext.at(input);
@@ -83,7 +88,8 @@ ${inProse(AVAILABLE_LANGUAGES)} are available; ${inProse(UPCOMING_LANGUAGES)} ar
language as Language,
StabilityChoice.for(language as Language, stability).stabilityLevel(),
force,
- terminal.isInteractive()
+ terminal.isInteractive(),
+ gitInit
);
outro(result);
}
diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts
new file mode 100644
index 000000000..6d232d534
--- /dev/null
+++ b/src/infrastructure/git-service.ts
@@ -0,0 +1,159 @@
+import { execa, ExecaError } from 'execa';
+import { err, ok, Result } from 'neverthrow';
+import { DirectoryPath } from '../types/file/directoryPath.js';
+import { FileName } from '../types/file/fileName.js';
+import { FilePath } from '../types/file/filePath.js';
+import { CommitId } from '../types/git/commit-id.js';
+import { GitVersion } from '../types/git/git-version.js';
+import { TreeId } from '../types/git/tree-id.js';
+import { errorMessage } from '../utils/error-utils.js';
+import { canonical, withDirPath } from './tmp-extensions.js';
+
+/** The system git through its plumbing. Nothing here throws: a failure is what git said, in its words. */
+export class GitService {
+ /** The git on PATH; none when there is no such command or it does not answer. */
+ public async version(): Promise {
+ try {
+ const { stdout } = await execa('git', ['--version']);
+ return GitVersion.parse(stdout);
+ } catch {
+ return GitVersion.none();
+ }
+ }
+
+ /** The root of the repository enclosing `directory`, spelt as the OS does, or none when nothing does. */
+ public async topLevel(directory: DirectoryPath): Promise> {
+ const ran = await run(['rev-parse', '--show-toplevel'], { cwd: directory, exitCodes: [0, 128] });
+ if (ran.isErr()) {
+ return err(ran.error);
+ }
+ if (ran.value.exitCode === 0) {
+ return ok(new DirectoryPath(canonical(ran.value.stdout.trim())));
+ }
+ return /not a git repository/i.test(ran.value.stderr) ? ok(undefined) : err(failed('rev-parse', ran.value.stderr));
+ }
+
+ /** A repository of its own in `directory`, on `main`; git creates the directory, and its parents, when they are not there yet. */
+ public async init(directory: DirectoryPath): Promise> {
+ return (await run(['init', '--quiet', '--initial-branch=main', directory.toString()])).map(() => undefined);
+ }
+
+ /**
+ * Every file below `directory`, whatever any ignore rule says, written as a tree into the repository
+ * at `root` through a temporary index, so the repository's own index is never touched.
+ */
+ public async treeOf(root: DirectoryPath, directory: DirectoryPath): Promise> {
+ const gitDir = await run(['rev-parse', '--absolute-git-dir'], { cwd: root });
+ if (gitDir.isErr()) {
+ return err(gitDir.error);
+ }
+ return withDirPath(async (temporary) => {
+ const env = {
+ GIT_DIR: gitDir.value.stdout.trim(),
+ GIT_WORK_TREE: directory.toString(),
+ GIT_INDEX_FILE: new FilePath(temporary, new FileName('index')).toString()
+ };
+ // `--force` takes what the ignore rules would leave out; `--sparse` reaches paths a sparse checkout keeps out of its cone.
+ const added = await run(['add', '--all', '--force', '--sparse', '--', '.'], { cwd: directory, env });
+ if (added.isErr()) {
+ return err(added.error);
+ }
+ const written = await run(['write-tree'], { cwd: directory, env });
+ if (written.isErr()) {
+ return err(written.error);
+ }
+ return read('write-tree', written.value.stdout.trim(), TreeId.fromGit);
+ });
+ }
+
+ /** A commit of `tree` by the CLI, on `parent` where there is one. */
+ public async commitTree(
+ root: DirectoryPath,
+ tree: TreeId,
+ parent: CommitId | undefined,
+ message: string
+ ): Promise> {
+ const env = {
+ GIT_AUTHOR_NAME: AUTHOR.name,
+ GIT_AUTHOR_EMAIL: AUTHOR.email,
+ GIT_COMMITTER_NAME: AUTHOR.name,
+ GIT_COMMITTER_EMAIL: AUTHOR.email
+ };
+ const ran = await run(
+ ['commit-tree', tree.toString(), ...(parent === undefined ? [] : ['-p', parent.toString()]), '-m', message],
+ { cwd: root, env }
+ );
+ if (ran.isErr()) {
+ return err(ran.error);
+ }
+ return read('commit-tree', ran.value.stdout.trim(), CommitId.fromGit);
+ }
+
+ public async updateRef(root: DirectoryPath, ref: string, commit: CommitId): Promise> {
+ return (await run(['update-ref', ref, commit.toString()], { cwd: root })).map(() => undefined);
+ }
+
+ /** The commit `ref` points at, or none when there is no such ref. */
+ public async resolveRef(root: DirectoryPath, ref: string): Promise> {
+ const ran = await run(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { cwd: root, exitCodes: [0, 1] });
+ if (ran.isErr()) {
+ return err(ran.error);
+ }
+ return ran.value.exitCode === 0 ? read('rev-parse', ran.value.stdout.trim(), CommitId.fromGit) : ok(undefined);
+ }
+
+ public async treeOfCommit(root: DirectoryPath, commit: CommitId): Promise> {
+ const ran = await run(['rev-parse', `${commit}^{tree}`], { cwd: root });
+ if (ran.isErr()) {
+ return err(ran.error);
+ }
+ return read('rev-parse', ran.value.stdout.trim(), TreeId.fromGit);
+ }
+}
+
+const AUTHOR = { name: 'APIMatic CLI', email: 'developer@apimatic.io' } as const;
+
+type RunOptions = {
+ /** Where git runs; the process's own directory when none, for a command given the path it works on. */
+ cwd?: DirectoryPath;
+ env?: Record;
+ /** Exit codes that are an answer rather than a failure. */
+ exitCodes?: readonly number[];
+};
+
+type Ran = { stdout: string; stderr: string; exitCode: number };
+
+/** One git command, answering with what it printed; nothing here throws. */
+async function run(args: string[], options: RunOptions = {}): Promise> {
+ const { cwd, env = {}, exitCodes = [0] } = options;
+ try {
+ const result = await execa('git', args, {
+ cwd: cwd?.toString(),
+ // Git's own messages are read here, so they have to be in English.
+ env: { ...env, LC_ALL: 'C' },
+ reject: false
+ });
+ const stderr = String(result.stderr);
+ if (!exitCodes.includes(result.exitCode ?? -1)) {
+ // With no exit code git never ran (or was killed), so execa's own message says why.
+ const reason =
+ result.exitCode === undefined
+ ? result.shortMessage ?? 'git did not run'
+ : stderr.trim() || `exit code ${result.exitCode}`;
+ return err(failed(args[0], reason));
+ }
+ return ok({ stdout: result.stdout, stderr, exitCode: result.exitCode ?? -1 });
+ } catch (error) {
+ return err(failed(args[0], error instanceof ExecaError ? error.shortMessage : errorMessage(error)));
+ }
+}
+
+/** `text` as the value git's `command` prints there; none means a damaged repository, or a git this code does not know. */
+function read(command: string, text: string, parse: (text: string) => T | undefined): Result {
+ const value = parse(text);
+ return value === undefined ? err(`git ${command} printed what could not be read: ${text.trim()}`) : ok(value);
+}
+
+function failed(command: string, reason: string): string {
+ return `git ${command} failed: ${reason.trim()}`;
+}
diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts
index 4d6c0eb85..1726e98db 100644
--- a/src/prompts/sdk/generate.ts
+++ b/src/prompts/sdk/generate.ts
@@ -3,9 +3,10 @@ import { DirectoryPath } from '../../types/file/directoryPath.js';
import { FileProblem } from '../../types/file/file-problem.js';
import { format as f } from '../format.js';
import { Result } from 'neverthrow';
-import { notZippedForUpload, withSpinner } from '../prompt.js';
+import { noteWrapped, notZippedForUpload, withSpinner } from '../prompt.js';
import { ServiceError } from '../../infrastructure/service-error.js';
import { AVAILABLE_LANGUAGES, Language, languageLabel, UPCOMING_LANGUAGES } from '../../types/sdk/generate.js';
+import { GitVersion } from '../../types/git/git-version.js';
const names = (languages: readonly Language[]) => languages.map(languageLabel).join(', ');
@@ -86,4 +87,57 @@ export class SdkGeneratePrompts {
public sdkGenerated(sdk: DirectoryPath) {
log.info(`The generated SDK can be found at ${f.relativePath(sdk)}.`);
}
+
+ /** Git is not on PATH, or does not answer: the SDK is written as before, and edits to it will not survive the next run. */
+ public gitUnavailable() {
+ log.warn(
+ 'git is not available, so your customizations to the generated SDK will not persist across regenerations.'
+ );
+ }
+
+ /** The git found is older than the feature needs: the SDK is written as before, and edits to it will not survive the next run. */
+ public gitVersionNotSupported(found: GitVersion) {
+ log.warn(
+ `git ${found.toString()} was found, but ${GitVersion.required()} or newer is needed, ` +
+ 'so your customizations to the generated SDK will not persist across regenerations.'
+ );
+ }
+
+ /** Git is usable and the directory has a `.git`, yet git would not say what the repository is, so the run stops before generating. */
+ public repositoryNotInspected(sdk: DirectoryPath, reason: string) {
+ log.error(`The git repository in ${f.relativePath(sdk)} could not be inspected: ${reason}`);
+ }
+
+ /** How to keep edits from now on, should the developer make any, for an SDK written without a repository of its own. */
+ public gitInitSteps(sdk: DirectoryPath) {
+ const steps = [
+ `To customize ${f.relativePath(sdk)} and keep your changes when it is regenerated:`,
+ `1. Rerun with ${f.flag('git-init')} before you change anything.` +
+ ` It makes the folder a git repository of its own, and needs git ${GitVersion.required()} or newer.`,
+ '2. Then make your changes; each later run merges them into the new generation.'
+ ];
+ noteWrapped(steps.join('\n'), 'Next steps');
+ }
+
+ public repositoryInitialised(sdk: DirectoryPath) {
+ log.info(
+ `Initialised a git repository in ${f.relativePath(sdk)}, which keeps your customizations across regenerations.`
+ );
+ }
+
+ /** The SDK is on disk; without a repository nothing was recorded, so the next run has no base to merge against. */
+ public repositoryNotInitialised(sdk: DirectoryPath, reason: string) {
+ log.warn(
+ `The SDK was written, but ${f.relativePath(sdk)} could not be made a git repository: ${reason}\n` +
+ 'The next run will replace the SDK instead of merging your customizations into it.'
+ );
+ }
+
+ /** The SDK is on disk; without its snapshot the next run has no base to merge against. */
+ public snapshotNotRecorded(reason: string) {
+ log.warn(
+ `The SDK was written, but its snapshot could not be recorded: ${reason}\n` +
+ 'The next run will replace the SDK instead of merging your customizations into it.'
+ );
+ }
}
diff --git a/src/types/git-context.ts b/src/types/git-context.ts
new file mode 100644
index 000000000..01580f56d
--- /dev/null
+++ b/src/types/git-context.ts
@@ -0,0 +1,76 @@
+import { err, ok, Result } from 'neverthrow';
+import { FileService } from '../infrastructure/file-service.js';
+import { GitService } from '../infrastructure/git-service.js';
+import { canonical } from '../infrastructure/tmp-extensions.js';
+import { DirectoryPath } from './file/directoryPath.js';
+import { FileName } from './file/fileName.js';
+import { FilePath } from './file/filePath.js';
+import { CommitId } from './git/commit-id.js';
+import { GitVersion } from './git/git-version.js';
+
+/** The ref holding each generation as the server returned it, chained, off every branch of the SDK directory's own repository. */
+const PRISTINE_REF = 'refs/apimatic/pristine';
+
+/** The sign that git has a say in the directory: a folder, or a worktree's file. */
+const GIT_DIRECTORY = new FileName('.git');
+
+/** Git on the SDK directory: what git answers about it, one question per method, and what it does to it. */
+export class GitContext {
+ private readonly gitService = new GitService();
+ private readonly fileService = new FileService();
+
+ constructor(private readonly directory: DirectoryPath) {}
+
+ /** Whether the directory has a `.git` of its own, read from disk so no git is needed to know whether git will be. */
+ public async hasGitDirectory(): Promise {
+ return (
+ (await this.fileService.directoryExists(this.directory.join(GIT_DIRECTORY.toString()))) ||
+ (await this.fileService.fileExists(new FilePath(this.directory, GIT_DIRECTORY)))
+ );
+ }
+
+ /**
+ * Whether the directory is a repository as `git` sees it: the root of its own, not inside one that
+ * only encloses it, which is the developer's. False, with no git run, without a `.git` of its own
+ * or a git that can serve, so a directory that is not there yet is fine.
+ */
+ public async isRepository(git: GitVersion): Promise> {
+ if (!git.isSupported() || !(await this.hasGitDirectory())) {
+ return ok(false);
+ }
+ // Spelt as git spells the root, so an 8.3 alias on Windows still compares equal.
+ const real = new DirectoryPath(canonical(this.directory.toString()));
+ const root = await this.gitService.topLevel(real);
+ return root.map((found) => found !== undefined && found.isEqual(real));
+ }
+
+ /**
+ * Commits every file of `generated` under the pristine ref, chained onto the last snapshot, so the
+ * next run has this generation as its base. An identical generation keeps the last snapshot.
+ */
+ public async recordSnapshot(generated: DirectoryPath): Promise> {
+ const tree = await this.gitService.treeOf(this.directory, generated);
+ if (tree.isErr()) {
+ return err(tree.error);
+ }
+ const last = await this.gitService.resolveRef(this.directory, PRISTINE_REF);
+ if (last.isErr()) {
+ return err(last.error);
+ }
+ if (last.value !== undefined) {
+ const lastTree = await this.gitService.treeOfCommit(this.directory, last.value);
+ if (lastTree.isErr()) {
+ return err(lastTree.error);
+ }
+ if (lastTree.value.isEqual(tree.value)) {
+ return ok(last.value);
+ }
+ }
+ const commit = await this.gitService.commitTree(this.directory, tree.value, last.value, 'apimatic: pristine');
+ if (commit.isErr()) {
+ return err(commit.error);
+ }
+ const moved = await this.gitService.updateRef(this.directory, PRISTINE_REF, commit.value);
+ return moved.map(() => commit.value);
+ }
+}
diff --git a/src/types/git/commit-id.ts b/src/types/git/commit-id.ts
new file mode 100644
index 000000000..31e778b7c
--- /dev/null
+++ b/src/types/git/commit-id.ts
@@ -0,0 +1,15 @@
+import { isObjectId } from './object-id.js';
+
+/** The id of a commit, as git prints it. */
+export class CommitId {
+ private constructor(private readonly sha: string) {}
+
+ /** From git: 40 or 64 hex digits, else none. */
+ public static fromGit(sha: string): CommitId | undefined {
+ return isObjectId(sha) ? new CommitId(sha) : undefined;
+ }
+
+ public toString(): string {
+ return this.sha;
+ }
+}
diff --git a/src/types/git/git-version.ts b/src/types/git/git-version.ts
new file mode 100644
index 000000000..dbf09b578
--- /dev/null
+++ b/src/types/git/git-version.ts
@@ -0,0 +1,43 @@
+const REQUIRED = { major: 2, minor: 38 } as const;
+
+type Parts = { readonly major: number; readonly minor: number; readonly patch: number };
+
+/** The version of the git on PATH as `git --version` reports it, or none. */
+export class GitVersion {
+ private static readonly NONE = new GitVersion(undefined);
+
+ private constructor(private readonly parts: Parts | undefined) {}
+
+ /** From the output of `git --version`, such as `git version 2.47.1.windows.1`; none for anything else. */
+ public static parse(output: string): GitVersion {
+ const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(output);
+ return match === null
+ ? GitVersion.NONE
+ : new GitVersion({ major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3] ?? 0) });
+ }
+
+ /** No git for this run: none on PATH, one that does not answer, or one that was not looked for. */
+ public static none(): GitVersion {
+ return GitVersion.NONE;
+ }
+
+ public isFound(): boolean {
+ return this.parts !== undefined;
+ }
+
+ /** 2.38 brought `merge-tree --write-tree`, which keeping edits across regenerations is built on; none supports nothing. */
+ public isSupported(): boolean {
+ return (
+ this.parts !== undefined &&
+ (this.parts.major > REQUIRED.major || (this.parts.major === REQUIRED.major && this.parts.minor >= REQUIRED.minor))
+ );
+ }
+
+ public static required(): string {
+ return `${REQUIRED.major}.${REQUIRED.minor}`;
+ }
+
+ public toString(): string {
+ return this.parts === undefined ? 'none' : `${this.parts.major}.${this.parts.minor}.${this.parts.patch}`;
+ }
+}
diff --git a/src/types/git/object-id.ts b/src/types/git/object-id.ts
new file mode 100644
index 000000000..80421c6d9
--- /dev/null
+++ b/src/types/git/object-id.ts
@@ -0,0 +1,4 @@
+/** A git object id as git prints it: 40 hex digits, or 64 in a SHA-256 repository. */
+const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
+
+export const isObjectId = (value: string): boolean => OBJECT_ID.test(value);
diff --git a/src/types/git/tree-id.ts b/src/types/git/tree-id.ts
new file mode 100644
index 000000000..8092f90ed
--- /dev/null
+++ b/src/types/git/tree-id.ts
@@ -0,0 +1,19 @@
+import { isObjectId } from './object-id.js';
+
+/** The id of a git tree: a directory's contents, which a commit points at. */
+export class TreeId {
+ private constructor(private readonly sha: string) {}
+
+ /** From git: 40 or 64 hex digits, else none. */
+ public static fromGit(sha: string): TreeId | undefined {
+ return isObjectId(sha) ? new TreeId(sha) : undefined;
+ }
+
+ public isEqual(other: TreeId): boolean {
+ return this.sha === other.sha;
+ }
+
+ public toString(): string {
+ return this.sha;
+ }
+}
diff --git a/test/actions/sdk/generate-destination.test.ts b/test/actions/sdk/generate-destination.test.ts
new file mode 100644
index 000000000..bbd495304
--- /dev/null
+++ b/test/actions/sdk/generate-destination.test.ts
@@ -0,0 +1,241 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { expect } from 'chai';
+import { execa } from 'execa';
+import { err } from 'neverthrow';
+import sinon from 'sinon';
+import { GenerateAction } from '../../../src/actions/sdk/generate.js';
+import { GitService } from '../../../src/infrastructure/git-service.js';
+import { canonical } from '../../../src/infrastructure/tmp-extensions.js';
+import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js';
+import { CommandMetadata } from '../../../src/types/common/command-metadata.js';
+import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
+import { GitVersion } from '../../../src/types/git/git-version.js';
+import { ProjectContext } from '../../../src/types/project-context.js';
+import { Language, Stability } from '../../../src/types/sdk/generate.js';
+import { Files, stubSdkGeneration, writeFiles } from '../../helpers/sdk-generation.js';
+import { describeCommit, snapshotFiles } from '../../helpers/snapshots.js';
+
+const COMMAND_METADATA: CommandMetadata = { commandName: 'sdk generate', shell: 'test' };
+const GENERATED: Files = { 'README.md': '# sdk' };
+/** Where the SDK directory's own repository keeps each generation as the server returned it. */
+const PRISTINE_REF = 'refs/apimatic/pristine';
+
+/** How the SDK directory is written: decided before the upload, from git and what the directory holds. */
+describe('GenerateAction (sdk) — the destination', () => {
+ let root: string;
+ let generates: (files: Files) => Promise;
+ let generateSdk: sinon.SinonStub;
+ const git = new GitService();
+
+ const sdk = () => new DirectoryPath(path.join(root, 'sdk', 'python'));
+ const at = (relative: string) => path.join(sdk().toString(), relative);
+ const read = (relative: string) => fs.readFileSync(at(relative), 'utf8');
+ const write = (relative: string, contents: string) => writeFiles(sdk().toString(), { [relative]: contents });
+ const entries = () => fs.readdirSync(sdk().toString()).sort();
+ /** The commit the pristine ref names in the SDK directory's own repository, or none. */
+ const pristine = async () => (await git.resolveRef(sdk(), PRISTINE_REF))._unsafeUnwrap();
+ const refsIn = async (cwd: string) => (await execa('git', ['for-each-ref', 'refs/apimatic'], { cwd })).stdout;
+ const gitInitSteps = () => SdkGeneratePrompts.prototype.gitInitSteps as sinon.SinonStub;
+
+ const execute = async (options: { force?: boolean; canAsk?: boolean; gitInit?: boolean; files?: Files } = {}) => {
+ await generates(options.files ?? GENERATED);
+ return new GenerateAction(new DirectoryPath(root), COMMAND_METADATA, 'auth-key').execute(
+ ProjectContext.in(new DirectoryPath(root)),
+ sdk(),
+ Language.PYTHON,
+ Stability.STABLE,
+ options.force ?? true,
+ options.canAsk ?? false,
+ options.gitInit ?? false
+ );
+ };
+
+ /** The SDK directory as a repository of its own, with nothing recorded in it yet. */
+ const ownRepository = async () => {
+ fs.mkdirSync(sdk().toString(), { recursive: true });
+ (await git.init(sdk()))._unsafeUnwrap();
+ };
+
+ beforeEach(() => {
+ root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-destination-')));
+ ({ generates, generateSdk } = stubSdkGeneration(root));
+ sinon.stub(SdkGeneratePrompts.prototype, 'sdkGenerated');
+ sinon.stub(SdkGeneratePrompts.prototype, 'repositoryInitialised');
+ sinon.stub(SdkGeneratePrompts.prototype, 'gitInitSteps');
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ describe('with a git that cannot serve the feature', () => {
+ for (const [found, unavailable, versionNamed] of [
+ [
+ 'no git, or one that does not answer',
+ () => {
+ sinon.stub(GitService.prototype, 'version').resolves(GitVersion.none());
+ return sinon.stub(SdkGeneratePrompts.prototype, 'gitUnavailable');
+ },
+ undefined
+ ],
+ [
+ 'an older git',
+ () => {
+ sinon.stub(GitService.prototype, 'version').resolves(GitVersion.parse('git version 2.30.2'));
+ return sinon.stub(SdkGeneratePrompts.prototype, 'gitVersionNotSupported');
+ },
+ '2.30.2'
+ ]
+ ] as const) {
+ it(`with ${found} and --git-init, warns that customizations will not persist and overwrites, without the steps`, async () => {
+ const warned = unavailable();
+
+ expect((await execute({ gitInit: true })).isSuccess()).to.be.true;
+
+ expect(warned.calledOnce).to.be.true;
+ expect(warned.firstCall.args[0]?.toString()).to.equal(versionNamed);
+ expect(entries()).to.deep.equal(['README.md']);
+ expect(gitInitSteps().called).to.be.false;
+ });
+
+ it(`with ${found}, warns and replaces the SDK directory's own repository, its .git included`, async () => {
+ await ownRepository();
+ const warned = unavailable();
+
+ expect((await execute()).isSuccess()).to.be.true;
+
+ expect(warned.calledOnce).to.be.true;
+ expect(entries()).to.deep.equal(['README.md']);
+ });
+ }
+ });
+
+ it('does not look for git without a repository of its own or --git-init, and warns about a missing one only once it does', async () => {
+ const version = sinon.stub(GitService.prototype, 'version').resolves(GitVersion.none());
+ const gitUnavailable = sinon.stub(SdkGeneratePrompts.prototype, 'gitUnavailable');
+
+ expect((await execute()).isSuccess()).to.be.true;
+ expect(version.called).to.be.false;
+ expect(gitInitSteps().calledOnce).to.be.true;
+
+ expect((await execute({ gitInit: true })).isSuccess()).to.be.true;
+ expect(gitUnavailable.calledOnce).to.be.true;
+ expect(fs.existsSync(at('.git'))).to.be.false;
+ expect(gitInitSteps().calledOnce).to.be.true;
+ });
+
+ describe('when the SDK directory is not a repository of its own', () => {
+ for (const [how, canAsk, force] of [
+ ['in a terminal', true, false],
+ ['without a terminal', false, false],
+ ['with --force', true, true]
+ ] as const) {
+ it(`makes it one with --git-init and records into it, ${how}`, async () => {
+ const repositoryInitialised = SdkGeneratePrompts.prototype.repositoryInitialised as sinon.SinonStub;
+
+ expect((await execute({ force, canAsk, gitInit: true })).isSuccess()).to.be.true;
+
+ expect(repositoryInitialised.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(fs.readFileSync(at('.git/HEAD'), 'utf-8').trim()).to.equal('ref: refs/heads/main');
+ const recorded = await pristine();
+ expect(recorded).to.not.be.undefined;
+ expect([...(await snapshotFiles(sdk(), recorded!)).keys()]).to.deep.equal(['README.md']);
+ });
+
+ it(`writes the SDK without a repository otherwise, then gives the steps that keep edits, ${how}`, async () => {
+ expect((await execute({ force, canAsk })).isSuccess()).to.be.true;
+
+ expect(gitInitSteps().firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(gitInitSteps().calledAfter(SdkGeneratePrompts.prototype.sdkGenerated as sinon.SinonStub)).to.be.true;
+ expect(entries()).to.deep.equal(['README.md']);
+ });
+ }
+
+ it('leaves a project repository that only encloses it alone', async () => {
+ (await git.init(new DirectoryPath(root)))._unsafeUnwrap();
+
+ expect((await execute()).isSuccess()).to.be.true;
+
+ expect(gitInitSteps().calledOnce).to.be.true;
+ expect(fs.existsSync(at('.git'))).to.be.false;
+ expect(await refsIn(root)).to.equal('');
+ });
+
+ it('makes it a repository inside the project repository with --git-init, keeping the snapshots there', async () => {
+ (await git.init(new DirectoryPath(root)))._unsafeUnwrap();
+
+ expect((await execute({ gitInit: true })).isSuccess()).to.be.true;
+
+ expect(await pristine()).to.not.be.undefined;
+ expect(await refsIn(root)).to.equal('');
+ });
+ });
+
+ describe('when the SDK directory is a repository of its own', () => {
+ it('records into it, made afresh after the save, and keeps what was there out of the snapshot', async () => {
+ await ownRepository();
+ write('old.py', 'old');
+ const repositoryInitialised = SdkGeneratePrompts.prototype.repositoryInitialised as sinon.SinonStub;
+
+ expect((await execute()).isSuccess()).to.be.true;
+
+ expect(repositoryInitialised.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(entries()).to.deep.equal(['.git', 'README.md']);
+ expect([...(await snapshotFiles(sdk(), (await pristine())!)).keys()]).to.deep.equal(['README.md']);
+ expect(gitInitSteps().called).to.be.false;
+ });
+
+ // Until a regeneration merges, the save remakes the repository, so each run's snapshot starts a chain of its own.
+ it('starts the snapshots over on a later run', async () => {
+ await ownRepository();
+ expect((await execute()).isSuccess()).to.be.true;
+ const first = (await pristine())!;
+
+ expect((await execute({ files: { 'README.md': '# sdk v2' } })).isSuccess()).to.be.true;
+
+ const second = (await pristine())!;
+ expect(second).to.not.deep.equal(first);
+ expect((await describeCommit(sdk(), second)).parents).to.deep.equal([]);
+ expect(read('README.md')).to.equal('# sdk v2');
+ });
+
+ it('still succeeds, with a warning, when the snapshot cannot be recorded', async () => {
+ await ownRepository();
+ sinon.stub(GitService.prototype, 'commitTree').resolves(err('git broke'));
+ const snapshotNotRecorded = sinon.stub(SdkGeneratePrompts.prototype, 'snapshotNotRecorded');
+
+ expect((await execute()).isSuccess()).to.be.true;
+
+ expect(read('README.md')).to.equal('# sdk');
+ expect(snapshotNotRecorded.firstCall.args).to.deep.equal(['git broke']);
+ });
+
+ it('reports a repository git could not inspect, before generating', async () => {
+ await ownRepository();
+ sinon.stub(GitService.prototype, 'topLevel').resolves(err('git broke'));
+ const notInspected = sinon.stub(SdkGeneratePrompts.prototype, 'repositoryNotInspected');
+
+ expect((await execute()).isFailed()).to.be.true;
+
+ expect(notInspected.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(notInspected.firstCall.args[1]).to.equal('git broke');
+ expect(generateSdk.called).to.be.false;
+ });
+ });
+
+ it('still succeeds, with a warning and the SDK written, when the directory cannot be made a repository', async () => {
+ sinon.stub(GitService.prototype, 'init').resolves(err('git broke'));
+ const notInitialised = sinon.stub(SdkGeneratePrompts.prototype, 'repositoryNotInitialised');
+
+ expect((await execute({ gitInit: true })).isSuccess()).to.be.true;
+
+ expect(read('README.md')).to.equal('# sdk');
+ expect(fs.existsSync(at('.git'))).to.be.false;
+ expect(notInitialised.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(notInitialised.firstCall.args[1]).to.equal('git broke');
+ expect(gitInitSteps().called).to.be.false;
+ });
+});
diff --git a/test/actions/sdk/generate.test.ts b/test/actions/sdk/generate.test.ts
index 21151cc36..ccee7a24b 100644
--- a/test/actions/sdk/generate.test.ts
+++ b/test/actions/sdk/generate.test.ts
@@ -1,60 +1,49 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
-import { Buffer } from 'node:buffer';
-import { Readable } from 'node:stream';
import { expect } from 'chai';
-import { err, ok } from 'neverthrow';
+import { err } from 'neverthrow';
import sinon from 'sinon';
import { GenerateAction } from '../../../src/actions/sdk/generate.js';
-import { SdkGenerationService } from '../../../src/infrastructure/services/sdk-generation-service.js';
-import { ZipService } from '../../../src/infrastructure/zip-service.js';
import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js';
import { CommandMetadata } from '../../../src/types/common/command-metadata.js';
import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
-import { FileName } from '../../../src/types/file/fileName.js';
-import { FilePath } from '../../../src/types/file/filePath.js';
import { FileProblem } from '../../../src/types/file/file-problem.js';
import { ProjectContext } from '../../../src/types/project-context.js';
import { Language, Stability } from '../../../src/types/sdk/generate.js';
import { TempContext } from '../../../src/types/temp-context.js';
+import { Files, stubSdkGeneration, writeFiles } from '../../helpers/sdk-generation.js';
const COMMAND_METADATA: CommandMetadata = { commandName: 'sdk generate', shell: 'test' };
+const GENERATED: Files = { 'README.md': '# sdk' };
describe('GenerateAction (sdk)', () => {
let root: string;
- let sdkArchive: Buffer;
+ let generates: (files: Files) => Promise;
let generateSdk: sinon.SinonStub;
const sdk = () => new DirectoryPath(path.join(root, 'sdk', 'python'));
const at = (relative: string) => path.join(sdk().toString(), relative);
+ const read = (relative: string) => fs.readFileSync(at(relative), 'utf-8');
- const execute = (options: { force?: boolean; canAsk?: boolean; destination?: DirectoryPath } = {}) =>
- new GenerateAction(new DirectoryPath(root), COMMAND_METADATA, 'auth-key').execute(
+ const execute = async (options: { force?: boolean; canAsk?: boolean; destination?: DirectoryPath } = {}) => {
+ await generates(GENERATED);
+ return new GenerateAction(new DirectoryPath(root), COMMAND_METADATA, 'auth-key').execute(
ProjectContext.in(new DirectoryPath(root)),
options.destination ?? sdk(),
Language.PYTHON,
Stability.STABLE,
options.force ?? true,
- options.canAsk ?? false
+ options.canAsk ?? false,
+ false
);
+ };
- beforeEach(async () => {
+ beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-generate-'));
- fs.mkdirSync(path.join(root, 'src', 'spec'), { recursive: true });
- fs.writeFileSync(path.join(root, 'src', 'spec', 'openapi.json'), '{}');
-
- const generated = path.join(root, 'generated');
- fs.mkdirSync(generated);
- fs.writeFileSync(path.join(generated, 'README.md'), '# sdk');
- const archive = new FilePath(new DirectoryPath(root), new FileName('sdk.zip'));
- (await new ZipService().archive(new DirectoryPath(generated), archive))._unsafeUnwrap();
- sdkArchive = fs.readFileSync(archive.toString());
-
- sinon.stub(SdkGeneratePrompts.prototype, 'generateSdk').callsFake((fn) => fn);
- generateSdk = sinon
- .stub(SdkGenerationService.prototype, 'generateSdk')
- .callsFake(async () => ok(Readable.from([sdkArchive])));
+ ({ generates, generateSdk } = stubSdkGeneration(root));
+ sinon.stub(SdkGeneratePrompts.prototype, 'sdkGenerated');
+ sinon.stub(SdkGeneratePrompts.prototype, 'gitInitSteps');
});
afterEach(() => {
@@ -65,7 +54,7 @@ describe('GenerateAction (sdk)', () => {
it('expands the generated SDK into the SDK directory', async () => {
expect((await execute()).isSuccess()).to.be.true;
- expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# sdk');
+ expect(read('README.md')).to.equal('# sdk');
});
it('writes into the destination itself, with no language directory below it', async () => {
@@ -103,20 +92,17 @@ describe('GenerateAction (sdk)', () => {
});
it('reports a generated SDK it cannot expand, with the reason it could not', async () => {
- sdkArchive = Buffer.from('not a zip');
+ const problem: FileProblem = { kind: 'unzipFailed', reason: 'Invalid or unsupported zip format' };
+ sinon.stub(TempContext.prototype, 'unzip').resolves(err(problem));
const sdkNotUnzipped = sinon.stub(SdkGeneratePrompts.prototype, 'sdkNotUnzipped');
expect((await execute()).isFailed()).to.be.true;
- const problem = sdkNotUnzipped.firstCall.args[0];
- expect(problem.kind).to.equal('unzipFailed');
- expect(problem.reason).to.not.be.empty;
+
+ expect(sdkNotUnzipped.firstCall.args[0]).to.equal(problem);
});
describe('the overwrite question', () => {
- beforeEach(() => {
- fs.mkdirSync(sdk().toString(), { recursive: true });
- fs.writeFileSync(at('README.md'), '# mine');
- });
+ beforeEach(() => writeFiles(sdk().toString(), { 'README.md': '# mine' }));
it('stops before generating when the developer declines', async () => {
const overwriteSdk = sinon.stub(SdkGeneratePrompts.prototype, 'overwriteSdk').resolves(false);
@@ -126,7 +112,7 @@ describe('GenerateAction (sdk)', () => {
expect(overwriteSdk.firstCall.args[0].isEqual(sdk())).to.be.true;
expect(generateSdk.called).to.be.false;
- expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# mine');
+ expect(read('README.md')).to.equal('# mine');
});
it('goes ahead on yes', async () => {
@@ -134,7 +120,7 @@ describe('GenerateAction (sdk)', () => {
expect((await execute({ force: false, canAsk: true })).isSuccess()).to.be.true;
- expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# sdk');
+ expect(read('README.md')).to.equal('# sdk');
});
it('is not asked where nobody can answer it: the run fails, naming --force', async () => {
@@ -146,7 +132,7 @@ describe('GenerateAction (sdk)', () => {
expect(overwriteSdk.called).to.be.false;
expect(cannotAsk.firstCall.args[0].isEqual(sdk())).to.be.true;
expect(generateSdk.called).to.be.false;
- expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# mine');
+ expect(read('README.md')).to.equal('# mine');
});
it('is skipped by --force', async () => {
@@ -155,7 +141,7 @@ describe('GenerateAction (sdk)', () => {
expect((await execute({ force: true, canAsk: true })).isSuccess()).to.be.true;
expect(overwriteSdk.called).to.be.false;
- expect(fs.readFileSync(at('README.md'), 'utf-8')).to.equal('# sdk');
+ expect(read('README.md')).to.equal('# sdk');
});
});
});
diff --git a/test/helpers/sdk-generation.ts b/test/helpers/sdk-generation.ts
new file mode 100644
index 000000000..17d007f0e
--- /dev/null
+++ b/test/helpers/sdk-generation.ts
@@ -0,0 +1,50 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { Readable } from 'node:stream';
+import { ok } from 'neverthrow';
+import sinon from 'sinon';
+import { SdkGenerationService } from '../../src/infrastructure/services/sdk-generation-service.js';
+import { ZipService } from '../../src/infrastructure/zip-service.js';
+import { SdkGeneratePrompts } from '../../src/prompts/sdk/generate.js';
+import { DirectoryPath } from '../../src/types/file/directoryPath.js';
+import { FileName } from '../../src/types/file/fileName.js';
+import { FilePath } from '../../src/types/file/filePath.js';
+
+export type Files = Record;
+
+export function writeFiles(directory: string, files: Files): void {
+ for (const [relative, contents] of Object.entries(files)) {
+ const target = path.join(directory, relative);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, contents);
+ }
+}
+
+/**
+ * A project with a spec at `root`, and a generation service that answers with a zip of whatever
+ * `generates` was last given. The stubs last until `sinon.restore()`.
+ */
+export function stubSdkGeneration(root: string): {
+ generates: (files: Files) => Promise;
+ generateSdk: sinon.SinonStub;
+} {
+ writeFiles(root, { 'src/spec/openapi.json': '{}' });
+ sinon.stub(SdkGeneratePrompts.prototype, 'generateSdk').callsFake((fn) => fn);
+ const generateSdk = sinon.stub(SdkGenerationService.prototype, 'generateSdk');
+ return {
+ generateSdk,
+ generates: async (files) => {
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-generated-'));
+ try {
+ writeFiles(path.join(scratch, 'sdk'), files);
+ const archive = new FilePath(new DirectoryPath(scratch), new FileName('sdk.zip'));
+ (await new ZipService().archive(new DirectoryPath(path.join(scratch, 'sdk')), archive))._unsafeUnwrap();
+ const bytes = fs.readFileSync(archive.toString());
+ generateSdk.callsFake(() => Promise.resolve(ok(Readable.from([bytes]))));
+ } finally {
+ fs.rmSync(scratch, { recursive: true, force: true });
+ }
+ }
+ };
+}
diff --git a/test/helpers/snapshots.ts b/test/helpers/snapshots.ts
new file mode 100644
index 000000000..f7876036c
--- /dev/null
+++ b/test/helpers/snapshots.ts
@@ -0,0 +1,31 @@
+import { execa } from 'execa';
+import { DirectoryPath } from '../../src/types/file/directoryPath.js';
+import { CommitId } from '../../src/types/git/commit-id.js';
+
+/** A commit's parents, author and subject, as `git show` reports them. */
+export async function describeCommit(
+ root: DirectoryPath,
+ commit: CommitId
+): Promise<{ parents: CommitId[]; authorName: string; subject: string }> {
+ const { stdout } = await execa('git', ['show', '--no-patch', '--format=%P%n%an%n%s', commit.toString()], {
+ cwd: root.toString()
+ });
+ const [parents, authorName, subject] = stdout.split('\n');
+ return {
+ parents: parents === '' ? [] : parents.split(' ').map((sha) => CommitId.fromGit(sha)!),
+ authorName,
+ subject
+ };
+}
+
+/** The files a snapshot commit holds, by path below the SDK directory, as text. */
+export async function snapshotFiles(root: DirectoryPath, commit: CommitId): Promise> {
+ const cwd = root.toString();
+ const listed = await execa('git', ['ls-tree', '-r', '-z', '--name-only', commit.toString()], { cwd });
+ const files = new Map();
+ for (const file of listed.stdout.split('\0').filter((entry) => entry !== '')) {
+ const shown = await execa('git', ['cat-file', '-p', `${commit}:${file}`], { cwd, stripFinalNewline: false });
+ files.set(file, shown.stdout);
+ }
+ return files;
+}
diff --git a/test/infrastructure/git-service.test.ts b/test/infrastructure/git-service.test.ts
new file mode 100644
index 000000000..aca7f360c
--- /dev/null
+++ b/test/infrastructure/git-service.test.ts
@@ -0,0 +1,146 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { expect } from 'chai';
+import { execa } from 'execa';
+import { GitService } from '../../src/infrastructure/git-service.js';
+import { canonical } from '../../src/infrastructure/tmp-extensions.js';
+import { DirectoryPath } from '../../src/types/file/directoryPath.js';
+import { describeCommit, snapshotFiles } from '../helpers/snapshots.js';
+
+const REF = 'refs/apimatic/pristine';
+
+describe('GitService', () => {
+ let root: string;
+ let git: GitService;
+
+ const write = (directory: DirectoryPath, relative: string, contents: string) => {
+ const target = path.join(directory.toString(), relative);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, contents);
+ };
+
+ const repository = async (name = 'repo'): Promise => {
+ const directory = new DirectoryPath(path.join(root, name));
+ fs.mkdirSync(directory.toString(), { recursive: true });
+ (await git.init(directory))._unsafeUnwrap();
+ return directory;
+ };
+
+ beforeEach(() => {
+ root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'git-service-')));
+ git = new GitService();
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('reports the git on PATH, which this suite needs at 2.38 or newer', async () => {
+ const version = await git.version();
+
+ expect(version.isFound()).to.be.true;
+ expect(version.isSupported()).to.be.true;
+ });
+
+ it('answers none when there is no git on PATH', async () => {
+ const previous = process.env.PATH;
+ process.env.PATH = root;
+ try {
+ expect((await git.version()).isFound()).to.be.false;
+ } finally {
+ process.env.PATH = previous;
+ }
+ });
+
+ it('reports a git that cannot run in its own words, instead of taking it for absent', async () => {
+ const repo = await repository();
+ const broken = path.join(root, 'gitconfig');
+ fs.writeFileSync(broken, '[core' + String.fromCharCode(10));
+ const previous = process.env.GIT_CONFIG_GLOBAL;
+ process.env.GIT_CONFIG_GLOBAL = broken;
+ try {
+ const found = await git.topLevel(repo);
+
+ expect(found.isErr()).to.be.true;
+ expect(found._unsafeUnwrapErr()).to.match(/^git rev-parse failed: .*bad config/);
+ } finally {
+ if (previous === undefined) {
+ delete process.env.GIT_CONFIG_GLOBAL;
+ } else {
+ process.env.GIT_CONFIG_GLOBAL = previous;
+ }
+ }
+ });
+
+ it('finds the repository enclosing a directory, on main, and none outside one', async () => {
+ const repo = await repository();
+ const nested = repo.join('sdk', 'typescript');
+ fs.mkdirSync(nested.toString(), { recursive: true });
+ const outside = new DirectoryPath(path.join(root, 'elsewhere'));
+ fs.mkdirSync(outside.toString());
+
+ expect((await git.topLevel(nested))._unsafeUnwrap()?.isEqual(repo)).to.be.true;
+ expect((await git.topLevel(outside))._unsafeUnwrap()).to.be.undefined;
+ expect(fs.readFileSync(path.join(repo.toString(), '.git', 'HEAD'), 'utf-8').trim()).to.equal(
+ 'ref: refs/heads/main'
+ );
+ });
+
+ it('makes a repository on main where the directory is not there yet, creating it and its parents', async () => {
+ const directory = new DirectoryPath(path.join(root, 'deep er', 'sdk', 'python'));
+
+ (await git.init(directory))._unsafeUnwrap();
+
+ expect(fs.readFileSync(path.join(directory.toString(), '.git', 'HEAD'), 'utf-8').trim()).to.equal(
+ 'ref: refs/heads/main'
+ );
+ });
+
+ describe('treeOf', () => {
+ it('takes every file below a directory outside the repository, ignore rules or not', async () => {
+ const repo = await repository();
+ const generated = new DirectoryPath(path.join(root, 'generated'));
+ write(generated, '.gitignore', 'dist/\n');
+ write(generated, 'dist/index.js', 'built\n');
+ write(generated, 'README.md', '# sdk\n');
+
+ const tree = (await git.treeOf(repo, generated))._unsafeUnwrap();
+
+ const commit = (await git.commitTree(repo, tree, undefined, 'generated'))._unsafeUnwrap();
+ const files = await snapshotFiles(repo, commit);
+ expect([...files.keys()]).to.have.members(['.gitignore', 'dist/index.js', 'README.md']);
+ expect(files.get('README.md')).to.equal('# sdk\n');
+ });
+
+ it('leaves the repository’s own index and its .git alone', async () => {
+ const repo = await repository();
+ write(repo, 'README.md', '# sdk\n');
+
+ const tree = (await git.treeOf(repo, repo))._unsafeUnwrap();
+
+ const commit = (await git.commitTree(repo, tree, undefined, 'own'))._unsafeUnwrap();
+ expect([...(await snapshotFiles(repo, commit)).keys()]).to.deep.equal(['README.md']);
+ expect((await execa('git', ['ls-files'], { cwd: repo.toString() })).stdout).to.equal('');
+ });
+ });
+
+ it('commits a tree, moves a ref to it, and finds the commit and its tree again', async () => {
+ const repo = await repository();
+ write(repo, 'a.txt', 'a\n');
+ const tree = (await git.treeOf(repo, repo))._unsafeUnwrap();
+
+ const first = (await git.commitTree(repo, tree, undefined, 'first'))._unsafeUnwrap();
+ const second = (await git.commitTree(repo, tree, first, 'second'))._unsafeUnwrap();
+ (await git.updateRef(repo, REF, second))._unsafeUnwrap();
+
+ expect((await git.resolveRef(repo, REF))._unsafeUnwrap()).to.deep.equal(second);
+ expect((await git.resolveRef(repo, 'refs/apimatic/none'))._unsafeUnwrap()).to.be.undefined;
+ expect(await describeCommit(repo, second)).to.deep.equal({
+ parents: [first],
+ authorName: 'APIMatic CLI',
+ subject: 'second'
+ });
+ expect((await git.treeOfCommit(repo, first))._unsafeUnwrap().isEqual(tree)).to.be.true;
+ });
+});
diff --git a/test/prompts/sdk/generate.test.ts b/test/prompts/sdk/generate.test.ts
index f706c76ce..d89fc56a7 100644
--- a/test/prompts/sdk/generate.test.ts
+++ b/test/prompts/sdk/generate.test.ts
@@ -5,6 +5,7 @@ import { log } from '@clack/prompts';
import { ServiceError } from '../../../src/infrastructure/service-error.js';
import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js';
import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
+import { GitVersion } from '../../../src/types/git/git-version.js';
import { Language } from '../../../src/types/sdk/generate.js';
describe('SdkGeneratePrompts.languageNotAvailable', () => {
@@ -96,3 +97,95 @@ describe('SdkGeneratePrompts.sdkNotUnzipped', () => {
);
});
});
+
+describe('SdkGeneratePrompts, about git', () => {
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('warns what not having git costs, and what a git too old does, naming the version found', () => {
+ const warn = sinon.stub(log, 'warn');
+
+ new SdkGeneratePrompts().gitUnavailable();
+ new SdkGeneratePrompts().gitVersionNotSupported(GitVersion.parse('git version 2.30.2'));
+
+ expect(stripVTControlCharacters(warn.firstCall.args[0])).to.equal(
+ 'git is not available, so your customizations to the generated SDK will not persist across regenerations.'
+ );
+ expect(stripVTControlCharacters(warn.secondCall.args[0])).to.equal(
+ 'git 2.30.2 was found, but 2.38 or newer is needed, ' +
+ 'so your customizations to the generated SDK will not persist across regenerations.'
+ );
+ });
+});
+
+describe('SdkGeneratePrompts, about the repository', () => {
+ const sdk = new DirectoryPath(process.cwd()).join('sdk', 'python');
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('says which repository could not be inspected, in git’s words', () => {
+ const error = sinon.stub(log, 'error');
+
+ new SdkGeneratePrompts().repositoryNotInspected(sdk, 'git rev-parse failed: bad config');
+
+ expect(stripVTControlCharacters(error.firstCall.args[0])).to.equal(
+ "The git repository in './sdk/python' could not be inspected: git rev-parse failed: bad config"
+ );
+ });
+
+ it('gives the steps that keep edits, should the developer make any, when the SDK directory is not a repository', () => {
+ const write = sinon.stub(process.stdout, 'write').returns(true);
+ try {
+ new SdkGeneratePrompts().gitInitSteps(sdk);
+ } finally {
+ write.restore();
+ }
+ const note = stripVTControlCharacters(write.args.map(([chunk]) => String(chunk)).join(''));
+
+ const order = [
+ 'Next steps',
+ "To customize './sdk/python' and keep your changes when it is regenerated:",
+ '1. Rerun with --git-init before you change anything.' +
+ ' It makes the folder a git repository of its own, and needs git 2.38 or newer.',
+ '2. Then make your changes; each later run merges them into the new generation.'
+ ];
+ const at = order.map((text) => note.indexOf(text));
+ expect(at).to.not.include(-1);
+ expect(at).to.deep.equal([...at].sort((a, b) => a - b));
+ });
+
+ it('says a repository was initialised, naming the SDK directory', () => {
+ const info = sinon.stub(log, 'info');
+
+ new SdkGeneratePrompts().repositoryInitialised(sdk);
+
+ expect(stripVTControlCharacters(info.firstCall.args[0])).to.equal(
+ "Initialised a git repository in './sdk/python', which keeps your customizations across regenerations."
+ );
+ });
+
+ it('says the SDK was written without a repository when one could not be made, and what the next run does', () => {
+ const warn = sinon.stub(log, 'warn');
+
+ new SdkGeneratePrompts().repositoryNotInitialised(sdk, 'git broke');
+
+ expect(stripVTControlCharacters(warn.firstCall.args[0])).to.equal(
+ "The SDK was written, but './sdk/python' could not be made a git repository: git broke\n" +
+ 'The next run will replace the SDK instead of merging your customizations into it.'
+ );
+ });
+
+ it('says what the next run does without the snapshot: replace the SDK', () => {
+ const warn = sinon.stub(log, 'warn');
+
+ new SdkGeneratePrompts().snapshotNotRecorded('git broke');
+
+ expect(stripVTControlCharacters(warn.firstCall.args[0])).to.equal(
+ 'The SDK was written, but its snapshot could not be recorded: git broke\n' +
+ 'The next run will replace the SDK instead of merging your customizations into it.'
+ );
+ });
+});
diff --git a/test/types/git-context.test.ts b/test/types/git-context.test.ts
new file mode 100644
index 000000000..bce277dc2
--- /dev/null
+++ b/test/types/git-context.test.ts
@@ -0,0 +1,197 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { expect } from 'chai';
+import { execa } from 'execa';
+import { err } from 'neverthrow';
+import sinon from 'sinon';
+import { GitService } from '../../src/infrastructure/git-service.js';
+import { canonical } from '../../src/infrastructure/tmp-extensions.js';
+import { DirectoryPath } from '../../src/types/file/directoryPath.js';
+import { GitContext } from '../../src/types/git-context.js';
+import { GitVersion } from '../../src/types/git/git-version.js';
+import { describeCommit, snapshotFiles } from '../helpers/snapshots.js';
+
+/** Where the SDK directory's own repository keeps each generation as the server returned it. */
+const PRISTINE_REF = 'refs/apimatic/pristine';
+
+describe('GitContext', () => {
+ let root: string;
+ const git = new GitService();
+
+ const inRoot = (...segments: string[]) => path.join(root, ...segments);
+ const sdk = () => inRoot('sdk', 'python');
+ const context = (directory = sdk()) => new GitContext(new DirectoryPath(directory));
+ /** A git that can serve the feature, for the questions that need one. */
+ const USABLE = GitVersion.parse('git version 2.38.0');
+
+ const write = (file: string, contents: string) => {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, contents);
+ };
+
+ /** A directory of generated files, beside the SDK directory. */
+ const generated = (files: Record): DirectoryPath => {
+ const directory = fs.mkdtempSync(inRoot('generated-'));
+ for (const [relative, contents] of Object.entries(files)) {
+ write(path.join(directory, relative), contents);
+ }
+ return new DirectoryPath(directory);
+ };
+
+ const ownRepository = async (directory = sdk()) => {
+ fs.mkdirSync(directory, { recursive: true });
+ (await git.init(new DirectoryPath(directory)))._unsafeUnwrap();
+ };
+
+ /** The commit the pristine ref names in the repository at `directory`, or none. */
+ const pristineAt = async (directory: string) =>
+ (await git.resolveRef(new DirectoryPath(directory), PRISTINE_REF))._unsafeUnwrap();
+
+ beforeEach(() => {
+ root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'git-context-')));
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ describe('hasGitDirectory', () => {
+ it('is false without a .git of its own, whether the directory is there yet or not, inside a repository that only encloses it or not', async () => {
+ expect(await context().hasGitDirectory()).to.be.false;
+
+ fs.mkdirSync(inRoot('.git'), { recursive: true });
+ fs.mkdirSync(sdk(), { recursive: true });
+ expect(await context().hasGitDirectory()).to.be.false;
+ });
+
+ for (const [how, setUp] of [
+ ['a .git folder', () => fs.mkdirSync(inRoot('sdk', 'python', '.git'), { recursive: true })],
+ ['a worktree’s .git file', () => write(inRoot('sdk', 'python', '.git'), 'gitdir: ../.git/worktrees/python')]
+ ] as const) {
+ it(`is true with ${how}`, async () => {
+ setUp();
+
+ expect(await context().hasGitDirectory()).to.be.true;
+ });
+ }
+ });
+
+ describe('isRepository', () => {
+ it('is true for the SDK directory’s own repository, also inside one that encloses it, and records there', async () => {
+ await ownRepository(root);
+ await ownRepository();
+
+ expect((await context().isRepository(USABLE))._unsafeUnwrap()).to.be.true;
+
+ (await context().recordSnapshot(generated({ 'README.md': '# sdk' })))._unsafeUnwrap();
+ expect(await pristineAt(sdk())).to.not.be.undefined;
+ expect(await pristineAt(root)).to.be.undefined;
+ });
+
+ for (const [where, setUp] of [
+ ['inside a repository that only encloses it', () => ownRepository(root)],
+ ['outside any repository', async () => undefined]
+ ] as const) {
+ it(`is false for a .git folder git does not recognise, ${where}`, async () => {
+ await setUp();
+ fs.mkdirSync(inRoot('sdk', 'python', '.git'), { recursive: true });
+
+ expect((await context().isRepository(USABLE))._unsafeUnwrap()).to.be.false;
+ });
+ }
+
+ it('reports a git that does not answer', async () => {
+ await ownRepository();
+ sinon.stub(GitService.prototype, 'topLevel').resolves(err('git broke'));
+
+ expect((await context().isRepository(USABLE))._unsafeUnwrapErr()).to.equal('git broke');
+ });
+
+ it('is false without a .git of its own, running no git, so a directory that is not there yet is fine', async () => {
+ await ownRepository(root);
+ const topLevel = sinon.stub(GitService.prototype, 'topLevel');
+
+ expect((await context().isRepository(USABLE))._unsafeUnwrap()).to.be.false;
+
+ expect(topLevel.called).to.be.false;
+ });
+
+ for (const [what, git] of [
+ ['no git', GitVersion.none()],
+ ['a git too old', GitVersion.parse('git version 2.30.2')]
+ ] as const) {
+ it(`is false for ${what}, running none, even in the directory’s own repository`, async () => {
+ await ownRepository();
+ const topLevel = sinon.stub(GitService.prototype, 'topLevel');
+
+ expect((await context().isRepository(git))._unsafeUnwrap()).to.be.false;
+
+ expect(topLevel.called).to.be.false;
+ });
+ }
+ });
+
+ describe('recordSnapshot', () => {
+ it('records the first snapshot with no parent, under the pristine ref, with every file', async () => {
+ await ownRepository();
+
+ const commit = (
+ await context().recordSnapshot(
+ generated({ 'README.md': '# v1', 'src/index.py': 'x', '.gitignore': 'dist/\n', 'dist/x.py': 'x' })
+ )
+ )._unsafeUnwrap();
+
+ expect(await pristineAt(sdk())).to.deep.equal(commit);
+ expect(await describeCommit(new DirectoryPath(sdk()), commit)).to.deep.equal({
+ parents: [],
+ authorName: 'APIMatic CLI',
+ subject: 'apimatic: pristine'
+ });
+ const files = await snapshotFiles(new DirectoryPath(sdk()), commit);
+ expect([...files.keys()]).to.have.members(['README.md', 'src/index.py', '.gitignore', 'dist/x.py']);
+ expect(files.get('src/index.py')).to.equal('x');
+ });
+
+ it('records an SDK whose path holds a space and a folder starting with a dot, and finds it again', async () => {
+ const directory = inRoot('My API', 'sdk', '.python');
+ await ownRepository(directory);
+
+ const commit = (await context(directory).recordSnapshot(generated({ 'README.md': '# v1' })))._unsafeUnwrap();
+
+ expect(await pristineAt(directory)).to.deep.equal(commit);
+ expect((await context(directory).isRepository(USABLE))._unsafeUnwrap()).to.be.true;
+ });
+
+ it('chains the next snapshot onto the last and moves the ref', async () => {
+ await ownRepository();
+ const first = (await context().recordSnapshot(generated({ 'README.md': '# v1' })))._unsafeUnwrap();
+
+ const second = (await context().recordSnapshot(generated({ 'README.md': '# v2' })))._unsafeUnwrap();
+
+ expect((await describeCommit(new DirectoryPath(sdk()), second)).parents).to.deep.equal([first]);
+ expect(await pristineAt(sdk())).to.deep.equal(second);
+ expect((await snapshotFiles(new DirectoryPath(sdk()), first)).get('README.md')).to.equal('# v1');
+ });
+
+ it('keeps the last snapshot when the generation is identical', async () => {
+ await ownRepository();
+ const first = (await context().recordSnapshot(generated({ 'README.md': '# v1' })))._unsafeUnwrap();
+
+ const again = (await context().recordSnapshot(generated({ 'README.md': '# v1' })))._unsafeUnwrap();
+ const changed = (await context().recordSnapshot(generated({ 'README.md': '# v2' })))._unsafeUnwrap();
+
+ expect(again).to.deep.equal(first);
+ expect((await describeCommit(new DirectoryPath(sdk()), changed)).parents).to.deep.equal([first]);
+ });
+
+ it('leaves the repository’s own index alone', async () => {
+ await ownRepository();
+
+ (await context().recordSnapshot(generated({ 'README.md': '# v1' })))._unsafeUnwrap();
+
+ expect((await execa('git', ['ls-files'], { cwd: sdk() })).stdout).to.equal('');
+ });
+ });
+});
diff --git a/test/types/git/git-version.test.ts b/test/types/git/git-version.test.ts
new file mode 100644
index 000000000..1c10a3cd4
--- /dev/null
+++ b/test/types/git/git-version.test.ts
@@ -0,0 +1,23 @@
+import { expect } from 'chai';
+import { GitVersion } from '../../../src/types/git/git-version.js';
+
+describe('GitVersion', () => {
+ it('reads what git prints, on any platform, and is none for anything else', () => {
+ expect(GitVersion.parse('git version 2.47.1.windows.1').toString()).to.equal('2.47.1');
+ expect(GitVersion.parse('git version 2.39.5 (Apple Git-154)').toString()).to.equal('2.39.5');
+ expect(GitVersion.parse('git version 2.38').toString()).to.equal('2.38.0');
+ expect(GitVersion.parse('git version 2.38').isFound()).to.be.true;
+
+ expect(GitVersion.parse('not git').isFound()).to.be.false;
+ expect(GitVersion.parse('not git').toString()).to.equal('none');
+ expect(GitVersion.none().isFound()).to.be.false;
+ });
+
+ it('is supported from 2.38 on, never when none', () => {
+ expect(GitVersion.parse('git version 2.37.9').isSupported()).to.be.false;
+ expect(GitVersion.parse('git version 2.38.0').isSupported()).to.be.true;
+ expect(GitVersion.parse('git version 3.0.0').isSupported()).to.be.true;
+ expect(GitVersion.none().isSupported()).to.be.false;
+ expect(GitVersion.required()).to.equal('2.38');
+ });
+});
diff --git a/test/types/git/object-id.test.ts b/test/types/git/object-id.test.ts
new file mode 100644
index 000000000..fcc29d704
--- /dev/null
+++ b/test/types/git/object-id.test.ts
@@ -0,0 +1,17 @@
+import { expect } from 'chai';
+import { CommitId } from '../../../src/types/git/commit-id.js';
+import { TreeId } from '../../../src/types/git/tree-id.js';
+
+describe('CommitId and TreeId', () => {
+ it('take what git prints, 40 hex digits or 64 in a SHA-256 repository, and nothing else', () => {
+ expect(CommitId.fromGit('a'.repeat(40))?.toString()).to.equal('a'.repeat(40));
+ expect(TreeId.fromGit('b'.repeat(64))?.toString()).to.equal('b'.repeat(64));
+ expect(CommitId.fromGit('HEAD')).to.be.undefined;
+ expect(TreeId.fromGit('A'.repeat(40))).to.be.undefined;
+ });
+
+ it('compare two trees by id', () => {
+ expect(TreeId.fromGit('a'.repeat(40))!.isEqual(TreeId.fromGit('a'.repeat(40))!)).to.be.true;
+ expect(TreeId.fromGit('a'.repeat(40))!.isEqual(TreeId.fromGit('b'.repeat(40))!)).to.be.false;
+ });
+});
From 82fd92dba0b545f315dbdb68c9b16fe23a85973d Mon Sep 17 00:00:00 2001
From: Asad Ali <14asadali@gmail.com>
Date: Mon, 2 Nov 2026 18:57:32 +0500
Subject: [PATCH 03/28] feat(sdk): merge a regeneration into an SDK that keeps
your edits
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
---
.ai/plans/custom-code-phases.md | 33 +-
src/actions/sdk/generate.ts | 106 +++-
src/infrastructure/file-service.ts | 41 ++
src/infrastructure/git-service.ts | 322 +++++++++--
src/prompts/sdk/generate.ts | 112 +++-
src/types/common/value-map.ts | 51 ++
src/types/file/relativeFilePath.ts | 40 ++
src/types/git-context.ts | 294 +++++++++-
src/types/git/blob-id.ts | 15 +
src/types/git/merge-tree.ts | 99 ++++
src/types/git/tree-entry.ts | 5 +
src/types/sdk-context.ts | 20 -
src/types/sdk/file-content.ts | 19 +
src/types/sdk/generation-record.ts | 69 +++
src/types/sdk/merge-plan.ts | 181 ++++++
src/types/sdk/write-problem.ts | 4 +
test/actions/sdk/generate-destination.test.ts | 129 +++--
test/actions/sdk/generate-merge.test.ts | 267 +++++++++
test/actions/sdk/generate.test.ts | 15 +
test/infrastructure/file-service.test.ts | 84 +++
test/infrastructure/git-service.test.ts | 160 +++++-
test/prompts/sdk/generate.test.ts | 152 ++++-
test/types/file/relativeFilePath.test.ts | 33 ++
test/types/git-context.test.ts | 520 +++++++++++++++++-
test/types/sdk-context.test.ts | 46 --
test/types/sdk/file-content.test.ts | 27 +
test/types/sdk/generation-record.test.ts | 57 ++
test/types/sdk/merge-plan.test.ts | 134 +++++
28 files changed, 2816 insertions(+), 219 deletions(-)
create mode 100644 src/types/common/value-map.ts
create mode 100644 src/types/file/relativeFilePath.ts
create mode 100644 src/types/git/blob-id.ts
create mode 100644 src/types/git/merge-tree.ts
create mode 100644 src/types/git/tree-entry.ts
delete mode 100644 src/types/sdk-context.ts
create mode 100644 src/types/sdk/file-content.ts
create mode 100644 src/types/sdk/generation-record.ts
create mode 100644 src/types/sdk/merge-plan.ts
create mode 100644 src/types/sdk/write-problem.ts
create mode 100644 test/actions/sdk/generate-merge.test.ts
create mode 100644 test/types/file/relativeFilePath.test.ts
delete mode 100644 test/types/sdk-context.test.ts
create mode 100644 test/types/sdk/file-content.test.ts
create mode 100644 test/types/sdk/generation-record.test.ts
create mode 100644 test/types/sdk/merge-plan.test.ts
diff --git a/.ai/plans/custom-code-phases.md b/.ai/plans/custom-code-phases.md
index 6ab3acfe5..e3bd69146 100644
--- a/.ai/plans/custom-code-phases.md
+++ b/.ai/plans/custom-code-phases.md
@@ -157,9 +157,9 @@ Behaviour:
"Found a git repository" is said only by the first run to record in it, the one that finds
no record; a later run, merging or recording afresh, no longer says it.
A run that merges does not ask to overwrite, since it writes only what changed; the question
- stays for every save. The merge needs `.git` to survive the write, so the save that keeps it
- and the check that counts every entry but `.git` (a `hasFilesOtherThanMetadata`, name open)
- may move to `GitContext`; if nothing then reads `SdkContext`, it is deleted.
+ stays for every save. The merge needs `.git` to survive the write: the save that keeps it and
+ the check that counts every entry but `.git` are `GitContext.save` and `hasFilesBesidesGit`,
+ and `SdkContext` is gone.
2. Snapshot the generation (every file) and the directory as it is (its ignore rules honoured,
except that every path the base holds goes in), both with line endings normalised.
3. Merge the two over the base with `merge-tree`, renames paired both ways. From git's merge
@@ -169,18 +169,39 @@ Behaviour:
5. Remove before writing. Write markers relabelled "Your changes" and "Generated by APIMatic",
each file with its own line ending (else the generator's), and skip a file whose bytes would
not change. Remove the folders a removal leaves empty, below the SDK directory.
-6. A write that fails puts back what it touched from the previous snapshot and names the
- file; if that fails too, the restore command is given. Failed.
+6. A write that fails names the file, and the directory is put back from the previous
+ snapshot; if that fails too, the restore command is given. Failed.
7. Record the new generation chained on the base; an identical generation keeps the last
commit, and `.apimatic/generation.json` names it, written only when changed. Then the
summary: counts of added, updated, merged and removed files, and the merged and kept files
by name.
8. With conflicts: list them with their kinds and how to rerun; nothing written. Failed.
+Slices: 2.1, done, the record and the save that keeps `.git`: `GenerationRecord` and
+`.apimatic/generation.json` written by every recording (the snapshot's message names the
+generation) and read before the question, "Found" only on the first record, the
+snapshot-missing warning without a remote, init before the save again, the hidden-entries
+count, `GitContext.save`, `hasFilesBesidesGit`, `generationRecord` (none when there is no record, the file written by `recordContents`) and `hasSnapshot`, `SdkContext`
+deleted. 2.2, done, the merge: items 2, 3, 7 and 8 and the writes and removals of item 5, the
+question skipped, the `--force` replacement and its warning; `GitContext.merge` (both trees,
+`merge-tree`, the diffs, the plan) and `write` (removals, then writes whose bytes differ);
+`GitService` with the `-c` settings on every call, normalised trees, `treeOfDirectory`,
+`listTree`, `readBlobs`, `mergeTree`, `diffTrees`; `RelativeFilePath`, `BlobId`, `TreeEntry`,
+the `merge-tree` output types and parser, `ValueMap`, `MergePlan` with `from` and
+`hasConflicts`; the `customCodeFailed`, `conflictsNotAsked`, `sdkNotWritten`, `sdkMerged`,
+`recordedSdkReplaced` and `snapshotNotRecordedAfterMerge` prompts. 2.3, done, the write made
+safe and exact: `GitContext.keepPrevious` (the directory as it is, byte for byte, under
+`refs/apimatic/previous`), a step of the run before `write`, which lands the plan (its bytes read
+by `merge`) and names the step that failed; the run then puts every path the plan names back
+through `restore` and says whether the directory is back, with the restore command when it is not;
+`WriteProblem` is the file, operation and reason, `removeEmptyDirectories` after a removal, each file
+written with its own line ending else the generator's, the binary rule in `file-content`, and
+`GitService.treeOfDirectoryAsIs`.
+
Touches: `GenerationRecord` and the record file, read by the inspection and written after every
recording; `SnapshotRef` with the previous ref; the runner's `-c` settings and the attributes
file that store trees with line endings normalised; `GitService.hasCommit`, `listTree`, `tree`
-of the working tree (normalised and as is), `readBlobs`, `mergeTree`, `diff`; `BlobId`,
+of the working tree (normalised and as is), `readBlobs` (contents in the order asked), `mergeTree`, `diff`; `BlobId`,
`RelativeFilePath`, `ValueMap`, the `merge-tree` and `diff-tree` output types and parsers;
`FileService.getBytes`, `writeBytes`,
`removeEmptyDirectories`; the merge plan and the summary as pure data; binary and line-ending
diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts
index 948f98990..eae4b75e6 100644
--- a/src/actions/sdk/generate.ts
+++ b/src/actions/sdk/generate.ts
@@ -9,8 +9,8 @@ import { isAvailableLanguage, Language, Stability } from '../../types/sdk/genera
import { ProjectContext } from '../../types/project-context.js';
import { GitContext } from '../../types/git-context.js';
import { GitVersion } from '../../types/git/git-version.js';
-import { SdkContext } from '../../types/sdk-context.js';
import { GitService } from '../../infrastructure/git-service.js';
+import { GenerationRecord } from '../../types/sdk/generation-record.js';
export class GenerateAction {
private readonly prompts: SdkGeneratePrompts = new SdkGeneratePrompts();
@@ -61,22 +61,37 @@ export class GenerateAction {
const gitContext = new GitContext(sdk);
const needsGit = gitInit || (await gitContext.hasGitDirectory());
- const git = needsGit ? await this.gitService.version() : GitVersion.none();
- if (needsGit && !git.isFound()) {
+ const gitVersion = needsGit ? await this.gitService.version() : GitVersion.none();
+ if (needsGit && !gitVersion.isFound()) {
this.prompts.gitUnavailable();
}
- if (git.isFound() && !git.isSupported()) {
- this.prompts.gitVersionNotSupported(git);
+ if (gitVersion.isFound() && !gitVersion.isSupported()) {
+ this.prompts.gitVersionNotSupported(gitVersion);
}
- const repository = await gitContext.isRepository(git);
- if (repository.isErr()) {
- this.prompts.repositoryNotInspected(sdk, repository.error);
+ const isRepository = await gitContext.isRepository(gitVersion);
+ if (isRepository.isErr()) {
+ this.prompts.repositoryNotInspected(sdk, isRepository.error);
return ActionResult.failed();
}
- const records = git.isSupported() && (gitInit || repository.value);
+ const generationRecord = isRepository.value ? await gitContext.generationRecord() : GenerationRecord.none();
+ const baseCommitId = generationRecord.baseFor(language, stability);
+ const hasSnapshot = await gitContext.hasSnapshot(baseCommitId);
+ if (hasSnapshot.isErr()) {
+ this.prompts.repositoryNotInspected(sdk, hasSnapshot.error);
+ return ActionResult.failed();
+ }
+ if (baseCommitId !== undefined && !hasSnapshot.value) {
+ this.prompts.snapshotMissing(sdk);
+ }
+ // A base that is here is merged against; without one the SDK is written afresh.
+ const mergeBaseCommitId = hasSnapshot.value ? baseCommitId : undefined;
+ const shouldMakeRepository = gitVersion.isSupported() && gitInit && !isRepository.value;
+ const shouldRecord = shouldMakeRepository || isRepository.value;
+ const isRecordingFirstTime = isRepository.value && !generationRecord.isFound();
+ const shouldReplaceOldRecord = force && generationRecord.isFound() && mergeBaseCommitId === undefined;
- const sdkContext = new SdkContext(sdk);
- if (!force && (await sdkContext.exists())) {
+ // A merge writes only what changed, so it never asks; a save replaces everything, so it does.
+ if (!force && mergeBaseCommitId === undefined && (await gitContext.hasFilesBesidesGit())) {
if (!canAsk) {
this.prompts.cannotAskToOverwrite(sdk);
return ActionResult.failed();
@@ -117,22 +132,69 @@ export class GenerateAction {
return ActionResult.failed();
}
- const saved = await sdkContext.save(generated.value);
- if (records) {
- const initialised = await this.gitService.init(saved);
- if (initialised.isErr()) {
- this.prompts.repositoryNotInitialised(saved, initialised.error);
- } else {
- this.prompts.repositoryInitialised(saved);
- const recorded = await gitContext.recordSnapshot(generated.value);
- if (recorded.isErr()) {
- this.prompts.snapshotNotRecorded(recorded.error);
+ if (mergeBaseCommitId !== undefined) {
+ const mergePlan = await gitContext.merge(mergeBaseCommitId, generated.value);
+ if (mergePlan.isErr()) {
+ this.prompts.customCodeFailed(mergePlan.error);
+ return ActionResult.failed();
+ }
+ if (mergePlan.value.hasConflicts()) {
+ this.prompts.conflictsNotAsked(sdk, mergePlan.value.conflicts);
+ return ActionResult.failed();
+ }
+ const previous = await gitContext.keepPrevious(mergeBaseCommitId);
+ if (previous.isErr()) {
+ this.prompts.customCodeFailed(previous.error);
+ return ActionResult.failed();
+ }
+ const written = await gitContext.write(mergePlan.value, generated.value);
+ if (written.isErr()) {
+ this.prompts.sdkNotWritten(sdk, written.error);
+ const restored = await gitContext.restore(previous.value, mergePlan.value);
+ if (restored.isErr()) {
+ this.prompts.sdkNotRestored(sdk, previous.value, restored.error);
+ } else {
+ this.prompts.sdkRestored(sdk);
}
+ return ActionResult.failed();
+ }
+ const recorded = await gitContext.recordSnapshot(generated.value, language, stability);
+ if (recorded.isErr()) {
+ this.prompts.snapshotNotRecordedAfterMerge(recorded.error);
+ }
+ if (mergePlan.value.isEmpty()) {
+ this.prompts.sdkUnchanged(sdk);
+ } else {
+ this.prompts.sdkMerged(sdk, mergePlan.value.files);
+ }
+ return ActionResult.success();
+ }
+
+ // The directory becomes a repository only once there is a generation to put in it, so a failed generation leaves none behind.
+ if (shouldMakeRepository) {
+ const initialised = await this.gitService.init(sdk);
+ if (initialised.isErr()) {
+ this.prompts.repositoryNotInitialised(sdk, initialised.error);
+ return ActionResult.failed();
+ }
+ }
+ const saved = await gitContext.save(generated.value);
+ if (shouldMakeRepository) {
+ this.prompts.repositoryInitialised(saved);
+ } else if (isRecordingFirstTime) {
+ this.prompts.repositoryFound(saved);
+ } else if (shouldReplaceOldRecord) {
+ this.prompts.recordedSdkReplaced(saved);
+ }
+ if (shouldRecord) {
+ const recorded = await gitContext.recordSnapshot(generated.value, language, stability);
+ if (recorded.isErr()) {
+ this.prompts.snapshotNotRecorded(recorded.error);
}
}
this.prompts.sdkGenerated(saved);
// No repository keeps edits, none was asked for, and git is not what is missing: the steps make one.
- if (!records && (git.isSupported() || !needsGit)) {
+ if (!shouldRecord && (gitVersion.isSupported() || !needsGit)) {
this.prompts.gitInitSteps(saved);
}
return ActionResult.success();
diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts
index 0b3122a21..dddfbc0c4 100644
--- a/src/infrastructure/file-service.ts
+++ b/src/infrastructure/file-service.ts
@@ -1,3 +1,4 @@
+import { Buffer } from 'node:buffer';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import fsExtra from 'fs-extra';
@@ -100,6 +101,16 @@ export class FileService {
await fsExtra.emptyDir(dir.toString()); // removes everything inside, keeps the dir
}
+ /** Whether the directory holds anything but the named entries, hidden ones included; one that is not there holds nothing. */
+ public async hasEntriesExcluding(dir: DirectoryPath, excludeNames: FileName[]): Promise {
+ try {
+ const entries = await fsExtra.readdir(dir.toString());
+ return entries.some((entry) => !excludeNames.some((exclude) => exclude.toString() === entry));
+ } catch (error) {
+ return !(error instanceof Error && 'code' in error && error.code === 'ENOENT');
+ }
+ }
+
public async cleanDirectoryExcluding(dir: DirectoryPath, excludeNames: FileName[]): Promise {
await fsExtra.ensureDir(dir.toString());
const entries = await fsExtra.readdir(dir.toString());
@@ -113,6 +124,17 @@ export class FileService {
);
}
+ /** Removes `directory` and then each parent while it is empty, stopping below `within`, which stays whatever it holds. */
+ public async removeEmptyDirectories(directory: DirectoryPath, within: DirectoryPath): Promise {
+ for (let current = directory; within.isAncestorOf(current); current = current.join('..')) {
+ try {
+ await fsExtra.rmdir(current.toString());
+ } catch {
+ return;
+ }
+ }
+ }
+
public async createDirectoryIfNotExists(dir: DirectoryPath): Promise {
await fsExtra.ensureDir(dir.toString());
}
@@ -297,6 +319,25 @@ export class FileService {
return fs.createReadStream(filePath.toString());
}
+ public async getBytes(filePath: FilePath): Promise {
+ return new Uint8Array(await fsExtra.readFile(filePath.toString()));
+ }
+
+ /** The bytes as they are, with the directories above the file made as needed. */
+ public async writeBytes(filePath: FilePath, content: Uint8Array): Promise {
+ await fsExtra.ensureDir(filePath.directory().toString());
+ await fsExtra.writeFile(filePath.toString(), content);
+ }
+
+ /** As `writeBytes`, skipped when the file already holds these bytes, so nothing reloads. */
+ public async writeBytesIfChanged(filePath: FilePath, content: Uint8Array): Promise {
+ if ((await this.fileExists(filePath)) && Buffer.from(await this.getBytes(filePath)).equals(content)) {
+ return false;
+ }
+ await this.writeBytes(filePath, content);
+ return true;
+ }
+
public async getContents(filePath: FilePath): Promise {
return await fsExtra.readFile(filePath.toString(), 'utf-8');
}
diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts
index 6d232d534..3b5933de5 100644
--- a/src/infrastructure/git-service.ts
+++ b/src/infrastructure/git-service.ts
@@ -1,15 +1,25 @@
+import { Buffer } from 'node:buffer';
+import fs from 'node:fs';
+import { TextDecoder } from 'node:util';
import { execa, ExecaError } from 'execa';
import { err, ok, Result } from 'neverthrow';
import { DirectoryPath } from '../types/file/directoryPath.js';
import { FileName } from '../types/file/fileName.js';
import { FilePath } from '../types/file/filePath.js';
+import { RelativeFilePath } from '../types/file/relativeFilePath.js';
+import { BlobId } from '../types/git/blob-id.js';
import { CommitId } from '../types/git/commit-id.js';
import { GitVersion } from '../types/git/git-version.js';
+import { Change, MergeTreeOutput, parseMergeTree } from '../types/git/merge-tree.js';
+import { TreeEntry } from '../types/git/tree-entry.js';
import { TreeId } from '../types/git/tree-id.js';
import { errorMessage } from '../utils/error-utils.js';
import { canonical, withDirPath } from './tmp-extensions.js';
-/** The system git through its plumbing. Nothing here throws: a failure is what git said, in its words. */
+/**
+ * The system git through its plumbing, with the same `-c` settings on every call so a developer's
+ * configuration cannot change a tree or a merge. Nothing here throws: a failure is what git said, in its words.
+ */
export class GitService {
/** The git on PATH; none when there is no such command or it does not answer. */
public async version(): Promise {
@@ -38,32 +48,22 @@ export class GitService {
return (await run(['init', '--quiet', '--initial-branch=main', directory.toString()])).map(() => undefined);
}
- /**
- * Every file below `directory`, whatever any ignore rule says, written as a tree into the repository
- * at `root` through a temporary index, so the repository's own index is never touched.
- */
- public async treeOf(root: DirectoryPath, directory: DirectoryPath): Promise> {
- const gitDir = await run(['rev-parse', '--absolute-git-dir'], { cwd: root });
- if (gitDir.isErr()) {
- return err(gitDir.error);
- }
- return withDirPath(async (temporary) => {
- const env = {
- GIT_DIR: gitDir.value.stdout.trim(),
- GIT_WORK_TREE: directory.toString(),
- GIT_INDEX_FILE: new FilePath(temporary, new FileName('index')).toString()
- };
- // `--force` takes what the ignore rules would leave out; `--sparse` reaches paths a sparse checkout keeps out of its cone.
- const added = await run(['add', '--all', '--force', '--sparse', '--', '.'], { cwd: directory, env });
- if (added.isErr()) {
- return err(added.error);
- }
- const written = await run(['write-tree'], { cwd: directory, env });
- if (written.isErr()) {
- return err(written.error);
- }
- return read('write-tree', written.value.stdout.trim(), TreeId.fromGit);
- });
+ /** Every file below `directory`, whatever any ignore rule says, as a tree in the repository at `root`, with line endings normalised. */
+ public treeOf(root: DirectoryPath, directory: DirectoryPath): Promise> {
+ return tree(root, directory, 'all', 'normalized');
+ }
+
+ /** The repository's working tree as a tree: its ignore rules honoured except for the paths `including`, with line endings normalised. */
+ public treeOfDirectory(root: DirectoryPath, including: readonly RelativeFilePath[]): Promise> {
+ return tree(root, root, including, 'normalized');
+ }
+
+ /** As `treeOfDirectory`, with every byte as it is on disk, for a snapshot a restore must reproduce exactly. */
+ public treeOfDirectoryAsIs(
+ root: DirectoryPath,
+ including: readonly RelativeFilePath[]
+ ): Promise> {
+ return tree(root, root, including, 'asIs');
}
/** A commit of `tree` by the CLI, on `parent` where there is one. */
@@ -102,6 +102,16 @@ export class GitService {
return ran.value.exitCode === 0 ? read('rev-parse', ran.value.stdout.trim(), CommitId.fromGit) : ok(undefined);
}
+ /** Whether the repository holds `commit`. */
+ public async hasCommit(root: DirectoryPath, commit: CommitId): Promise> {
+ // `--verify --quiet` is exit 1 for an object the repository lacks, where `cat-file -e` would be a fatal 128.
+ const ran = await run(['rev-parse', '--verify', '--quiet', `${commit.toString()}^{commit}`], {
+ cwd: root,
+ exitCodes: [0, 1]
+ });
+ return ran.map((result) => result.exitCode === 0);
+ }
+
public async treeOfCommit(root: DirectoryPath, commit: CommitId): Promise> {
const ran = await run(['rev-parse', `${commit}^{tree}`], { cwd: root });
if (ran.isErr()) {
@@ -109,30 +119,154 @@ export class GitService {
}
return read('rev-parse', ran.value.stdout.trim(), TreeId.fromGit);
}
+
+ /** Every file below `tree`, with `/` between path segments. */
+ public async listTree(root: DirectoryPath, tree: TreeId | CommitId): Promise> {
+ const ran = await run(['ls-tree', '-r', '-z', tree.toString()], { cwd: root });
+ if (ran.isErr()) {
+ return err(ran.error);
+ }
+ const entries = ran.value.stdout.split('\0').filter((entry) => entry !== '');
+ return readAll('ls-tree', entries, (entry): TreeEntry | undefined => {
+ const [meta, filePath] = entry.split('\t');
+ const [mode, type, sha] = meta.split(' ');
+ const oid = BlobId.fromGit(sha);
+ const path = RelativeFilePath.fromGit(filePath);
+ return oid === undefined || path === undefined ? undefined : { mode, type: type as TreeEntry['type'], oid, path };
+ });
+ }
+
+ /** The contents of `ids`, in the order asked, one per id; an id the repository lacks fails the whole read. */
+ public async readBlobs(root: DirectoryPath, ids: readonly BlobId[]): Promise> {
+ if (ids.length === 0) {
+ return ok([]);
+ }
+ const ran = await runBytes(['cat-file', '--batch'], { cwd: root, input: ids.map(String).join('\n') });
+ if (ran.isErr()) {
+ return err(ran.error);
+ }
+ // execa hands a buffer-encoded stdout over as a bare Uint8Array, which cannot read its own text.
+ const stdout = Buffer.from(ran.value.stdout);
+ const contents: Uint8Array[] = [];
+ let offset = 0;
+ while (offset < stdout.length) {
+ const headerEnd = stdout.indexOf(0x0a, offset);
+ const [sha, type, size] = stdout.subarray(offset, headerEnd).toString('utf-8').split(' ');
+ offset = headerEnd + 1;
+ if (type === 'missing') {
+ return err(`object ${sha} is not in the repository`);
+ }
+ const length = Number(size);
+ contents.push(new Uint8Array(stdout.subarray(offset, offset + length)));
+ offset += length + 1;
+ }
+ return ok(contents);
+ }
+
+ /** `merge-tree --write-tree -z` of `ours` and `theirs`, read into the merged tree, the stages of every conflicted path and git's messages. */
+ public async mergeTree(
+ root: DirectoryPath,
+ ours: CommitId,
+ theirs: CommitId
+ ): Promise> {
+ return withDirPath(async (temporary) => {
+ const ran = await runBytes(['merge-tree', '--write-tree', '-z', ours.toString(), theirs.toString()], {
+ cwd: root,
+ attributesIn: temporary,
+ exitCodes: [0, 1]
+ });
+ if (ran.isErr()) {
+ return err(ran.error);
+ }
+ const parsed = parseMergeTree(new TextDecoder().decode(new Uint8Array(ran.value.stdout)).split('\0'));
+ return parsed === undefined ? err('git merge-tree printed what could not be read') : ok(parsed);
+ });
+ }
+
+ /** How `to` differs from `from`, file by file; a moved file is a deletion and an addition. */
+ public diffTrees(
+ root: DirectoryPath,
+ from: TreeId | CommitId,
+ to: TreeId | CommitId
+ ): Promise> {
+ return diff(root, from, to, '--no-renames');
+ }
+
+ /** As `diffTrees`, with a moved file as one entry naming both paths. */
+ public diffTreesPairingRenames(
+ root: DirectoryPath,
+ from: TreeId | CommitId,
+ to: TreeId | CommitId
+ ): Promise> {
+ return diff(root, from, to, '--find-renames');
+ }
}
const AUTHOR = { name: 'APIMatic CLI', email: 'developer@apimatic.io' } as const;
+// The developer's own configuration must not change a tree, a merge or its markers.
+const SETTINGS: readonly (readonly [string, string])[] = [
+ ['core.autocrlf', 'false'],
+ ['core.safecrlf', 'false'],
+ ['core.quotePath', 'false'],
+ ['merge.conflictStyle', 'merge'],
+ ['merge.renormalize', 'true'],
+ ['merge.renameLimit', '10000'],
+ ['diff.renameLimit', '10000'],
+ ['gc.auto', '0']
+];
+
type RunOptions = {
/** Where git runs; the process's own directory when none, for a command given the path it works on. */
cwd?: DirectoryPath;
env?: Record;
+ /** Where to put the attributes file that normalises line endings; none leaves the repository's own rules. */
+ attributesIn?: DirectoryPath;
+ input?: string;
/** Exit codes that are an answer rather than a failure. */
exitCodes?: readonly number[];
};
-type Ran = { stdout: string; stderr: string; exitCode: number };
+type Ran = { stdout: Out; stderr: string; exitCode: number };
+
+/** One git command, answering with what it printed as text; nothing here throws. */
+function run(args: string[], options: RunOptions = {}): Promise, string>> {
+ return spawn(args, options, 'utf8');
+}
-/** One git command, answering with what it printed; nothing here throws. */
-async function run(args: string[], options: RunOptions = {}): Promise> {
- const { cwd, env = {}, exitCodes = [0] } = options;
+/** As `run`, with what git printed as bytes, for output that is not text or is read by length. */
+function runBytes(args: string[], options: RunOptions = {}): Promise, string>> {
+ return spawn(args, options, 'buffer');
+}
+
+async function spawn(
+ args: string[],
+ options: RunOptions,
+ encoding: 'utf8' | 'buffer'
+): Promise, string>> {
+ const { cwd, env = {}, attributesIn, input, exitCodes = [0] } = options;
+ const settings: Result =
+ attributesIn === undefined
+ ? ok(SETTINGS)
+ : (await attributesFile(attributesIn)).map((file) => [...SETTINGS, ['core.attributesFile', file] as const]);
+ if (settings.isErr()) {
+ return err(settings.error);
+ }
try {
- const result = await execa('git', args, {
- cwd: cwd?.toString(),
- // Git's own messages are read here, so they have to be in English.
- env: { ...env, LC_ALL: 'C' },
- reject: false
- });
+ const result = await execa(
+ 'git',
+ [...settings.value.flatMap(([key, value]) => ['-c', `${key}=${value}`]), ...args],
+ {
+ cwd: cwd?.toString(),
+ // Git's own messages are read here, so they have to be in English.
+ env: { ...env, LC_ALL: 'C' },
+ input,
+ encoding: encoding as 'utf8',
+ // Bytes are read by length, and stripping would take a final CR with the newline.
+ stripFinalNewline: encoding === 'utf8',
+ reject: false
+ }
+ );
const stderr = String(result.stderr);
if (!exitCodes.includes(result.exitCode ?? -1)) {
// With no exit code git never ran (or was killed), so execa's own message says why.
@@ -142,18 +276,130 @@ async function run(args: string[], options: RunOptions = {}): Promise> {
+ const file = new FilePath(temporary, new FileName('attributes'));
+ try {
+ await fs.promises.writeFile(file.toString(), '* text=auto\n');
+ return ok(file.toString());
+ } catch (error) {
+ return err(errorMessage(error));
+ }
+}
+
+/**
+ * `directory` as a tree in the repository at `root`, through a temporary index so the repository's own
+ * is never touched: every file with `include` set to all, else the ignore rules honoured except for the paths given.
+ */
+async function tree(
+ root: DirectoryPath,
+ directory: DirectoryPath,
+ include: 'all' | readonly RelativeFilePath[],
+ lineEndings: 'normalized' | 'asIs'
+): Promise> {
+ const gitDir = await run(['rev-parse', '--absolute-git-dir'], { cwd: root });
+ if (gitDir.isErr()) {
+ return err(gitDir.error);
+ }
+ return withDirPath(async (temporary) => {
+ const env = {
+ GIT_DIR: gitDir.value.stdout.trim(),
+ GIT_WORK_TREE: directory.toString(),
+ GIT_INDEX_FILE: new FilePath(temporary, new FileName('index')).toString()
+ };
+ const inWorkTree = (args: string[], input?: string) =>
+ run(args, { cwd: directory, env, attributesIn: lineEndings === 'normalized' ? temporary : undefined, input });
+ // `--force` takes what the ignore rules would leave out; `--sparse` reaches paths a sparse checkout keeps out of its cone.
+ const added = await inWorkTree(['add', '--all', '--sparse', ...(include === 'all' ? ['--force'] : []), '--', '.']);
+ if (added.isErr()) {
+ return err(added.error);
+ }
+ const forced =
+ include === 'all' ? [] : include.filter((relative) => fs.existsSync(relative.in(directory).toString()));
+ if (forced.length > 0) {
+ const forcedAdded = await inWorkTree(
+ ['add', '--force', '--sparse', '--pathspec-from-file=-', '--pathspec-file-nul'],
+ forced.map(String).join('\0')
+ );
+ if (forcedAdded.isErr()) {
+ return err(forcedAdded.error);
+ }
+ }
+ const written = await inWorkTree(['write-tree']);
+ if (written.isErr()) {
+ return err(written.error);
+ }
+ return read('write-tree', written.value.stdout.trim(), TreeId.fromGit);
+ });
+}
+
+async function diff(
+ root: DirectoryPath,
+ from: TreeId | CommitId,
+ to: TreeId | CommitId,
+ renames: '--find-renames' | '--no-renames'
+): Promise> {
+ const ran = await run(['diff-tree', '-r', '-z', '--raw', renames, from.toString(), to.toString()], { cwd: root });
+ if (ran.isErr()) {
+ return err(ran.error);
+ }
+ const fields = ran.value.stdout.split('\0');
+ const entries: Change[] = [];
+ for (let index = 0; index < fields.length && fields[index] !== ''; ) {
+ const [, , , dstBlob, statusWithScore] = fields[index].slice(1).split(' ');
+ const status = statusWithScore[0] as Change['status'];
+ const renamed = status === 'R' || status === 'C';
+ const paths = readAll('diff-tree', fields.slice(index + 1, index + (renamed ? 3 : 2)), RelativeFilePath.fromGit);
+ if (paths.isErr()) {
+ return err(paths.error);
+ }
+ const [first, second] = paths.value;
+ if (status === 'D') {
+ entries.push({ status, path: first });
+ } else {
+ const blob = read('diff-tree', dstBlob, BlobId.fromGit);
+ if (blob.isErr()) {
+ return err(blob.error);
+ }
+ entries.push(
+ status === 'R' || status === 'C'
+ ? { status, from: first, path: second, blob: blob.value }
+ : { status, path: first, blob: blob.value }
+ );
+ }
+ index += paths.value.length + 1;
+ }
+ return ok(entries);
+}
+
/** `text` as the value git's `command` prints there; none means a damaged repository, or a git this code does not know. */
function read(command: string, text: string, parse: (text: string) => T | undefined): Result {
const value = parse(text);
return value === undefined ? err(`git ${command} printed what could not be read: ${text.trim()}`) : ok(value);
}
+function readAll(
+ command: string,
+ texts: readonly string[],
+ parse: (text: string) => T | undefined
+): Result {
+ const values: T[] = [];
+ for (const text of texts) {
+ const value = read(command, text, parse);
+ if (value.isErr()) {
+ return err(value.error);
+ }
+ values.push(value.value);
+ }
+ return ok(values);
+}
+
function failed(command: string, reason: string): string {
return `git ${command} failed: ${reason.trim()}`;
}
diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts
index 1726e98db..bd440f5a0 100644
--- a/src/prompts/sdk/generate.ts
+++ b/src/prompts/sdk/generate.ts
@@ -7,9 +7,35 @@ import { noteWrapped, notZippedForUpload, withSpinner } from '../prompt.js';
import { ServiceError } from '../../infrastructure/service-error.js';
import { AVAILABLE_LANGUAGES, Language, languageLabel, UPCOMING_LANGUAGES } from '../../types/sdk/generate.js';
import { GitVersion } from '../../types/git/git-version.js';
+import { CommitId } from '../../types/git/commit-id.js';
+import { Conflict, ConflictKind, MergeFiles } from '../../types/sdk/merge-plan.js';
+import { WriteProblem } from '../../types/sdk/write-problem.js';
+import { listedInProse } from '../../utils/string-utils.js';
const names = (languages: readonly Language[]) => languages.map(languageLabel).join(', ');
+const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`;
+
+const CONFLICT_LABELS: Record = {
+ bothChanged: 'changed by you and by the generator',
+ bothAdded: 'added by you and by the generator',
+ deletedByYou: 'deleted by you, changed by the generator',
+ noLongerGenerated: 'no longer generated',
+ binary: 'binary file changed by you and by the generator',
+ other: 'placed by git where it could, with both versions on disk'
+};
+
+const OPERATION_LABELS = { write: 'written', remove: 'removed' } as const;
+
+/** The generator's own files are a count, and so are the merged ones, which are also named below; the rest of yours are named. */
+const COUNTED_FILES = ['added', 'updated', 'merged', 'removed'] as const;
+
+/** The files of yours, each kind under its heading. */
+const NAMED_FILES = [
+ ['merged', 'Merged with your changes:'],
+ ['kept', 'Kept as yours:']
+] as const;
+
export class SdkGeneratePrompts {
/**
* Named rather than listed as flag values: a user whose language is on its way back reads
@@ -125,11 +151,91 @@ export class SdkGeneratePrompts {
);
}
- /** The SDK is on disk; without a repository nothing was recorded, so the next run has no base to merge against. */
+ public repositoryFound(sdk: DirectoryPath) {
+ log.info(`Found a git repository in ${f.relativePath(sdk)}, which keeps your customizations across regenerations.`);
+ }
+
+ /** The first run could not make the SDK directory a repository, so nothing was written. */
public repositoryNotInitialised(sdk: DirectoryPath, reason: string) {
+ log.error(
+ `A git repository could not be created in ${f.relativePath(sdk)}, where the SDK's snapshots would be kept: ` +
+ `${reason}\nNothing was written.`
+ );
+ }
+
+ /** The record names a snapshot the repository does not hold, so this generation starts a chain of its own. */
+ public snapshotMissing(sdk: DirectoryPath) {
log.warn(
- `The SDK was written, but ${f.relativePath(sdk)} could not be made a git repository: ${reason}\n` +
- 'The next run will replace the SDK instead of merging your customizations into it.'
+ `The snapshot ${f.relativePath(sdk)} was last generated from is not in its repository, ` +
+ 'so this generation is recorded afresh.'
+ );
+ }
+
+ /** `--force` replaced an SDK with a generation record, which may have held customizations; git still has the committed ones. */
+ public recordedSdkReplaced(sdk: DirectoryPath) {
+ log.warn(
+ `The new SDK could not be merged into ${f.relativePath(sdk)}, so ${f.flag('force')} replaced it, ` +
+ `and your customizations to it were not carried over. Those you committed are still in its git history, ` +
+ `and git -C ${f.relativePath(sdk)} diff shows what changed; uncommitted ones are gone.`
+ );
+ }
+
+ /** Git could not work out the merge, or keep the directory before the write, so nothing was written. */
+ public customCodeFailed(reason: string) {
+ log.error(`Your customizations could not be merged into the regenerated SDK: ${reason}\nNothing was written.`);
+ }
+
+ /** The conflicted files, with what each one is, for a run that writes none of them. */
+ public conflictsNotAsked(sdk: DirectoryPath, conflicts: readonly Conflict[]) {
+ const lines = conflicts.map(
+ (conflict) => `${f.relativePath(conflict.path.in(sdk))}\n ${CONFLICT_LABELS[conflict.kind]}`
+ );
+ noteWrapped([...lines, '', 'Nothing was written.'].join('\n'), `Conflicts in ${plural(conflicts.length, 'file')}`);
+ }
+
+ /** The file the write stopped at, and why. */
+ public sdkNotWritten(sdk: DirectoryPath, problem: WriteProblem) {
+ log.error(
+ `${f.relativePath(problem.file.in(sdk))} could not be ${OPERATION_LABELS[problem.operation]}: ${problem.reason}`
+ );
+ }
+
+ /** After a failed write, the directory is back as it was. */
+ public sdkRestored(sdk: DirectoryPath) {
+ log.info(`Nothing else was changed; ${f.relativePath(sdk)} is as it was.`);
+ }
+
+ /** After a failed write, the directory could not be put back either: the command that does, from the snapshot kept before the write. */
+ public sdkNotRestored(sdk: DirectoryPath, previous: CommitId, reason: string) {
+ log.error(
+ `The files written before it could not be put back either: ${reason}\n` +
+ `To restore ${f.relativePath(sdk)} as it was before this run:\n` +
+ ` git -C ${f.relativePath(sdk)} restore --source=${previous} --worktree -- .`
+ );
+ }
+
+ /** A merge with nothing to write: the generation is the one already on disk. */
+ public sdkUnchanged(sdk: DirectoryPath) {
+ log.info(`Regenerated the SDK at ${f.relativePath(sdk)}: nothing changed.`);
+ }
+
+ /** The counts of what the generator did, and by name the files of yours it merged into or left alone. */
+ public sdkMerged(sdk: DirectoryPath, files: MergeFiles) {
+ const counted = COUNTED_FILES.filter((kind) => files[kind].length > 0).map(
+ (kind) => `${plural(files[kind].length, 'file')} ${kind}`
+ );
+ const named = NAMED_FILES.filter(([kind]) => files[kind].length > 0).flatMap(([kind, heading]) => [
+ heading,
+ ...files[kind].map((path) => ` ${f.relativePath(path.in(sdk))}`)
+ ]);
+ log.info([`Regenerated the SDK at ${f.relativePath(sdk)}: ${listedInProse(counted)}.`, ...named].join('\n'));
+ }
+
+ /** The SDK is merged and on disk; the record still names the last snapshot, which the next run merges against again. */
+ public snapshotNotRecordedAfterMerge(reason: string) {
+ log.warn(
+ `The SDK was merged, but the new snapshot could not be recorded: ${reason}\n` +
+ 'The next run will merge against the previous one.'
);
}
diff --git a/src/types/common/value-map.ts b/src/types/common/value-map.ts
new file mode 100644
index 000000000..12b53c8c1
--- /dev/null
+++ b/src/types/common/value-map.ts
@@ -0,0 +1,51 @@
+/** What a key must spell itself as: two values that spell the same are one key. */
+type Spelled = { toString(): string };
+
+/** A map keyed by a value object rather than by reference, so a path read twice from git finds the same entry. */
+export class ValueMap {
+ private readonly entries = new Map();
+
+ constructor(pairs: Iterable = []) {
+ for (const [key, value] of pairs) {
+ this.set(key, value);
+ }
+ }
+
+ public set(key: K, value: V): this {
+ this.entries.set(key.toString(), { key, value });
+ return this;
+ }
+
+ public get(key: K): V | undefined {
+ return this.entries.get(key.toString())?.value;
+ }
+
+ public has(key: K): boolean {
+ return this.entries.has(key.toString());
+ }
+
+ public values(): V[] {
+ return [...this.entries.values()].map((entry) => entry.value);
+ }
+
+ public *[Symbol.iterator](): IterableIterator<[K, V]> {
+ for (const { key, value } of this.entries.values()) {
+ yield [key, value];
+ }
+ }
+}
+
+/** A set of value objects, by what they spell. */
+export class ValueSet {
+ private readonly members = new ValueMap();
+
+ constructor(keys: Iterable = []) {
+ for (const key of keys) {
+ this.members.set(key, key);
+ }
+ }
+
+ public has(key: K): boolean {
+ return this.members.has(key);
+ }
+}
diff --git a/src/types/file/relativeFilePath.ts b/src/types/file/relativeFilePath.ts
new file mode 100644
index 000000000..b6f97d3e2
--- /dev/null
+++ b/src/types/file/relativeFilePath.ts
@@ -0,0 +1,40 @@
+import path from 'node:path';
+import { DirectoryPath } from './directoryPath.js';
+import { FileName } from './fileName.js';
+import { FilePath } from './filePath.js';
+
+/**
+ * A file's place below some directory, spelt as git names a path inside a tree: `/` between the
+ * segments, no leading `/`, no `.` or `..`. The spelling is the same on every OS, so it goes to
+ * git and comes back unchanged; `in(directory)` is the file on disk.
+ */
+export class RelativeFilePath {
+ private constructor(private readonly value: string) {}
+
+ /** From git, which spells a path no other way; none for what git would never print. */
+ public static fromGit(value: string): RelativeFilePath | undefined {
+ const segments = value.split('/');
+ const wellFormed =
+ !value.includes('\\') &&
+ !path.win32.isAbsolute(value) &&
+ !path.posix.isAbsolute(value) &&
+ segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..');
+ return wellFormed ? new RelativeFilePath(value) : undefined;
+ }
+
+ public in(directory: DirectoryPath): FilePath {
+ return new FilePath(directory.join(path.posix.dirname(this.value)), this.name());
+ }
+
+ public name(): FileName {
+ return new FileName(path.posix.basename(this.value));
+ }
+
+ public isEqual(other: RelativeFilePath): boolean {
+ return this.value === other.value;
+ }
+
+ public toString(): string {
+ return this.value;
+ }
+}
diff --git a/src/types/git-context.ts b/src/types/git-context.ts
index 01580f56d..3063e54e4 100644
--- a/src/types/git-context.ts
+++ b/src/types/git-context.ts
@@ -2,19 +2,37 @@ import { err, ok, Result } from 'neverthrow';
import { FileService } from '../infrastructure/file-service.js';
import { GitService } from '../infrastructure/git-service.js';
import { canonical } from '../infrastructure/tmp-extensions.js';
+import { errorMessage } from '../utils/error-utils.js';
+import { ValueMap } from './common/value-map.js';
import { DirectoryPath } from './file/directoryPath.js';
import { FileName } from './file/fileName.js';
import { FilePath } from './file/filePath.js';
+import { RelativeFilePath } from './file/relativeFilePath.js';
import { CommitId } from './git/commit-id.js';
import { GitVersion } from './git/git-version.js';
+import { TreeEntry } from './git/tree-entry.js';
+import { TreeId } from './git/tree-id.js';
+import { Language, Stability } from './sdk/generate.js';
+import { GenerationRecord, recordContents } from './sdk/generation-record.js';
+import { lineEndingFor } from './sdk/file-content.js';
+import { MergePlan } from './sdk/merge-plan.js';
+import { WriteProblem } from './sdk/write-problem.js';
/** The ref holding each generation as the server returned it, chained, off every branch of the SDK directory's own repository. */
const PRISTINE_REF = 'refs/apimatic/pristine';
-/** The sign that git has a say in the directory: a folder, or a worktree's file. */
+/** The ref holding the directory as it was before the last merge wrote, byte for byte, so that write can be undone. */
+const PREVIOUS_REF = 'refs/apimatic/previous';
+
+/** The sign that git has a say in the directory, a folder or a worktree's file, and the one entry a save keeps. */
const GIT_DIRECTORY = new FileName('.git');
-/** Git on the SDK directory: what git answers about it, one question per method, and what it does to it. */
+/** Where the CLI keeps its record inside the SDK directory, and the file holding it. */
+const RECORD_DIRECTORY = '.apimatic';
+const RECORD_FILE = new FileName('generation.json');
+const RECORD_PATH = `${RECORD_DIRECTORY}/${RECORD_FILE.toString()}`;
+
+/** The SDK directory under git: what the disk and git answer about it, one question per method, and what a run does to it. */
export class GitContext {
private readonly gitService = new GitService();
private readonly fileService = new FileService();
@@ -29,6 +47,11 @@ export class GitContext {
);
}
+ /** Whether a save would remove anything: hidden entries such as `.github` count, only `.git` stays. */
+ public async hasFilesBesidesGit(): Promise {
+ return this.fileService.hasEntriesExcluding(this.directory, [GIT_DIRECTORY]);
+ }
+
/**
* Whether the directory is a repository as `git` sees it: the root of its own, not inside one that
* only encloses it, which is the developer's. False, with no git run, without a `.git` of its own
@@ -44,11 +67,198 @@ export class GitContext {
return root.map((found) => found !== undefined && found.isEqual(real));
}
+ /** The record the last recording left; none without a file, or with one a CLI did not write. */
+ public async generationRecord(): Promise {
+ const file = recordFile(this.directory);
+ if (!(await this.fileService.fileExists(file))) {
+ return GenerationRecord.none();
+ }
+ return GenerationRecord.parse(await this.fileService.getContents(file));
+ }
+
+ /** Whether the repository holds the snapshot a record names; false for none, with no git run. */
+ public async hasSnapshot(commit: CommitId | undefined): Promise> {
+ return commit === undefined ? ok(false) : this.gitService.hasCommit(this.directory, commit);
+ }
+
+ /** Replaces whatever the directory holds with the generated SDK, except its `.git`. */
+ public async save(generated: DirectoryPath): Promise {
+ await this.fileService.cleanDirectoryExcluding(this.directory, [GIT_DIRECTORY]);
+ await this.fileService.copyDirectoryContents(generated, this.directory);
+ return this.directory;
+ }
+
+ /**
+ * The fresh generation at `generated` merged over the directory as it is, against `base`, by git: what
+ * to write, with its bytes, and remove, what conflicts, and what happened to every other file. Nothing is written.
+ */
+ public async merge(base: CommitId, generated: DirectoryPath): Promise> {
+ const generatedTree = await this.gitService.treeOf(this.directory, generated);
+ if (generatedTree.isErr()) {
+ return err(generatedTree.error);
+ }
+ const baseTree = await this.gitService.treeOfCommit(this.directory, base);
+ if (baseTree.isErr()) {
+ return err(baseTree.error);
+ }
+ const baseEntries = await this.gitService.listTree(this.directory, baseTree.value);
+ if (baseEntries.isErr()) {
+ return err(baseEntries.error);
+ }
+ // Every path the base holds goes in, so a generated file an ignore rule matches is never taken for deleted.
+ const basePaths = baseEntries.value.map((entry) => entry.path);
+ const disk = await this.gitService.treeOfDirectory(this.directory, basePaths);
+ if (disk.isErr()) {
+ return err(disk.error);
+ }
+ // Yours is git's "ours" and the generation its "theirs", which is what the plan reads the stages by.
+ const yours = await this.gitService.commitTree(this.directory, disk.value, base, 'apimatic: your changes');
+ if (yours.isErr()) {
+ return err(yours.error);
+ }
+ const theirs = await this.gitService.commitTree(this.directory, generatedTree.value, base, 'apimatic: generated');
+ if (theirs.isErr()) {
+ return err(theirs.error);
+ }
+ const merged = await this.gitService.mergeTree(this.directory, yours.value, theirs.value);
+ if (merged.isErr()) {
+ return err(merged.error);
+ }
+ const yourChanges = await this.gitService.diffTreesPairingRenames(this.directory, baseTree.value, disk.value);
+ if (yourChanges.isErr()) {
+ return err(yourChanges.error);
+ }
+ const generatedChanges = await this.gitService.diffTreesPairingRenames(
+ this.directory,
+ baseTree.value,
+ generatedTree.value
+ );
+ if (generatedChanges.isErr()) {
+ return err(generatedChanges.error);
+ }
+ const applied = await this.gitService.diffTrees(this.directory, disk.value, merged.value.tree);
+ if (applied.isErr()) {
+ return err(applied.error);
+ }
+ const changed = applied.value.flatMap((change) => (change.status === 'D' ? [] : [change]));
+ const contents = await this.gitService.readBlobs(
+ this.directory,
+ changed.map((change) => change.blob)
+ );
+ if (contents.isErr()) {
+ return err(contents.error);
+ }
+ return ok(
+ MergePlan.from({
+ merge: merged.value,
+ // The CLI's own record is not an edit of yours.
+ yours: yourChanges.value.filter((change) => change.path.toString() !== RECORD_PATH),
+ generated: generatedChanges.value,
+ writes: changed.map((change, index) => ({ path: change.path, content: contents.value[index] })),
+ deletes: applied.value.flatMap((change) => (change.status === 'D' ? [change.path] : [])),
+ basePaths
+ })
+ );
+ }
+
/**
- * Commits every file of `generated` under the pristine ref, chained onto the last snapshot, so the
- * next run has this generation as its base. An identical generation keeps the last snapshot.
+ * Keeps the directory as it is, byte for byte, under the previous ref, replacing what the last run
+ * kept, so a write can be undone; every path `base` holds goes in, ignore rules or not.
*/
- public async recordSnapshot(generated: DirectoryPath): Promise> {
+ public async keepPrevious(base: CommitId): Promise> {
+ const baseEntries = await this.gitService.listTree(this.directory, base);
+ if (baseEntries.isErr()) {
+ return err(baseEntries.error);
+ }
+ const disk = await this.gitService.treeOfDirectoryAsIs(
+ this.directory,
+ baseEntries.value.map((entry) => entry.path)
+ );
+ if (disk.isErr()) {
+ return err(disk.error);
+ }
+ const commit = await this.gitService.commitTree(
+ this.directory,
+ disk.value,
+ undefined,
+ 'apimatic: before regeneration'
+ );
+ if (commit.isErr()) {
+ return err(commit.error);
+ }
+ const moved = await this.gitService.updateRef(this.directory, PREVIOUS_REF, commit.value);
+ return moved.map(() => commit.value);
+ }
+
+ /**
+ * Lands the plan: removals before writes, so a name that changed only in case lands on a
+ * filesystem that ignores case; each file with its own line ending, else the generator's, and
+ * only when its bytes differ. A step that fails is named and nothing is undone, so the caller
+ * can put the directory back from the snapshot it kept.
+ */
+ public async write(plan: MergePlan, generated: DirectoryPath): Promise> {
+ for (const path of plan.deletes) {
+ try {
+ await removeFile(this.fileService, path.in(this.directory), this.directory);
+ } catch (error) {
+ return err({ file: path, operation: 'remove', reason: errorMessage(error) });
+ }
+ }
+ for (const write of plan.writes) {
+ try {
+ const file = write.path.in(this.directory);
+ const sample = await firstOf(this.fileService, file, write.path.in(generated));
+ await this.fileService.writeBytesIfChanged(file, lineEndingFor(write.content, sample));
+ } catch (error) {
+ return err({ file: write.path, operation: 'write', reason: errorMessage(error) });
+ }
+ }
+ return ok(undefined);
+ }
+
+ /**
+ * Puts every path the plan names back as `previous` holds it, in the plan's order, removing first
+ * what it does not hold so a name that changed only in case comes back on a filesystem that
+ * ignores case. A path the write had not reached is already as it was, so the undo need not know how far it got.
+ */
+ public async restore(previous: CommitId, plan: MergePlan): Promise> {
+ const entries = await this.gitService.listTree(this.directory, previous);
+ if (entries.isErr()) {
+ return err(entries.error);
+ }
+ const kept = new ValueMap(entries.value.map((entry): [RelativeFilePath, TreeEntry] => [entry.path, entry]));
+ const named = [...plan.deletes, ...plan.writes.map((write) => write.path)];
+ const held = named.flatMap((path) => kept.get(path) ?? []);
+ const contents = await this.gitService.readBlobs(
+ this.directory,
+ held.map((entry) => entry.oid)
+ );
+ if (contents.isErr()) {
+ return err(contents.error);
+ }
+ try {
+ for (const path of named.filter((path) => !kept.has(path))) {
+ await removeFile(this.fileService, path.in(this.directory), this.directory);
+ }
+ for (const [index, entry] of held.entries()) {
+ await this.fileService.writeBytesIfChanged(entry.path.in(this.directory), contents.value[index]);
+ }
+ return ok(undefined);
+ } catch (error) {
+ return err(errorMessage(error));
+ }
+ }
+
+ /**
+ * Commits every file of `generated` under the pristine ref, chained onto the last snapshot, and
+ * writes the record naming it, so the next run for the same generation has this one as its base.
+ * An identical generation keeps the last snapshot.
+ */
+ public async recordSnapshot(
+ generated: DirectoryPath,
+ language: Language,
+ stability: Stability
+ ): Promise> {
const tree = await this.gitService.treeOf(this.directory, generated);
if (tree.isErr()) {
return err(tree.error);
@@ -57,20 +267,68 @@ export class GitContext {
if (last.isErr()) {
return err(last.error);
}
- if (last.value !== undefined) {
- const lastTree = await this.gitService.treeOfCommit(this.directory, last.value);
- if (lastTree.isErr()) {
- return err(lastTree.error);
- }
- if (lastTree.value.isEqual(tree.value)) {
- return ok(last.value);
- }
+ const snapshot = await snapshotOf(
+ this.gitService,
+ this.directory,
+ tree.value,
+ last.value,
+ `apimatic: pristine ${language} ${stability}`
+ );
+ if (snapshot.isErr()) {
+ return err(snapshot.error);
}
- const commit = await this.gitService.commitTree(this.directory, tree.value, last.value, 'apimatic: pristine');
- if (commit.isErr()) {
- return err(commit.error);
+ try {
+ await this.fileService.replaceContents(
+ recordFile(this.directory),
+ recordContents(language, stability, snapshot.value)
+ );
+ return ok(snapshot.value);
+ } catch (error) {
+ return err(errorMessage(error));
}
- const moved = await this.gitService.updateRef(this.directory, PRISTINE_REF, commit.value);
- return moved.map(() => commit.value);
}
}
+
+function recordFile(directory: DirectoryPath): FilePath {
+ return new FilePath(directory.join(RECORD_DIRECTORY), RECORD_FILE);
+}
+
+/** The bytes of the first of `files` that is there, for the line ending a written file follows; none when none is. */
+async function firstOf(files: FileService, ...candidates: FilePath[]): Promise {
+ for (const file of candidates) {
+ if (await files.fileExists(file)) {
+ return files.getBytes(file);
+ }
+ }
+ return undefined;
+}
+
+/** Removes the file and the directories that leaves empty, below `within`. */
+async function removeFile(files: FileService, file: FilePath, within: DirectoryPath): Promise {
+ await files.deleteFile(file);
+ await files.removeEmptyDirectories(file.directory(), within);
+}
+
+/** `last` when it already holds `tree`, else a commit of `tree` on `last` with `message`, which the pristine ref is moved to. */
+async function snapshotOf(
+ git: GitService,
+ root: DirectoryPath,
+ tree: TreeId,
+ last: CommitId | undefined,
+ message: string
+): Promise> {
+ if (last !== undefined) {
+ const lastTree = await git.treeOfCommit(root, last);
+ if (lastTree.isErr()) {
+ return err(lastTree.error);
+ }
+ if (lastTree.value.isEqual(tree)) {
+ return ok(last);
+ }
+ }
+ const commit = await git.commitTree(root, tree, last, message);
+ if (commit.isErr()) {
+ return err(commit.error);
+ }
+ return (await git.updateRef(root, PRISTINE_REF, commit.value)).map(() => commit.value);
+}
diff --git a/src/types/git/blob-id.ts b/src/types/git/blob-id.ts
new file mode 100644
index 000000000..a19bd6af7
--- /dev/null
+++ b/src/types/git/blob-id.ts
@@ -0,0 +1,15 @@
+import { isObjectId } from './object-id.js';
+
+/** The id of a git blob: one file's content, which a tree entry or a diff names. */
+export class BlobId {
+ private constructor(private readonly sha: string) {}
+
+ /** From git: 40 or 64 hex digits, else none. */
+ public static fromGit(sha: string): BlobId | undefined {
+ return isObjectId(sha) ? new BlobId(sha) : undefined;
+ }
+
+ public toString(): string {
+ return this.sha;
+ }
+}
diff --git a/src/types/git/merge-tree.ts b/src/types/git/merge-tree.ts
new file mode 100644
index 000000000..678f9e431
--- /dev/null
+++ b/src/types/git/merge-tree.ts
@@ -0,0 +1,99 @@
+import { RelativeFilePath } from '../file/relativeFilePath.js';
+import { BlobId } from './blob-id.js';
+import { TreeId } from './tree-id.js';
+
+/** One line of `diff-tree --raw`: how `path` differs between two trees, and the blob it holds in the second. */
+export type Change =
+ | { status: 'D'; path: RelativeFilePath }
+ | { status: 'A' | 'M' | 'T'; path: RelativeFilePath; blob: BlobId }
+ | { status: 'R' | 'C'; path: RelativeFilePath; from: RelativeFilePath; blob: BlobId };
+
+/** The stage each side of a conflicted path is kept at, as git numbers them. */
+export const STAGE = { base: 1, ours: 2, theirs: 3 } as const;
+
+/** One conflicted path's version on one side. */
+export type StageEntry = { mode: string; oid: BlobId; stage: 1 | 2 | 3; path: RelativeFilePath };
+
+/** `mode oid stagepath`, as `merge-tree -z` prints a stage entry; none for what git would never print. */
+export function parseStageEntry(entry: string): StageEntry | undefined {
+ const tab = entry.indexOf('\t');
+ const [mode, sha, stage] = entry.slice(0, tab).split(' ');
+ const oid = BlobId.fromGit(sha);
+ const path = RelativeFilePath.fromGit(entry.slice(tab + 1));
+ return oid === undefined || path === undefined
+ ? undefined
+ : { mode, oid, stage: Number(stage) as StageEntry['stage'], path };
+}
+
+/** What a merge message reports: a conflict of one of the shapes the CLI knows, another shape, or no conflict at all. */
+export type MergeMessageKind =
+ | 'contents'
+ | 'addAdd'
+ | 'modifyDelete'
+ | 'renameDelete'
+ | 'binary'
+ | 'otherConflict'
+ | 'informational';
+
+/** One of git's messages about the merge, such as `CONFLICT (modify/delete)` for a path. */
+export type MergeMessage = { paths: RelativeFilePath[]; kind: MergeMessageKind; message: string };
+
+/** What `git merge-tree --write-tree` reports: the merged tree, the stages of every conflicted path, and its messages. */
+export type MergeTreeOutput = { tree: TreeId; stages: StageEntry[]; messages: MergeMessage[] };
+
+const CONFLICT_PREFIX = 'CONFLICT (';
+
+const CONFLICT_KINDS: Readonly> = {
+ 'CONFLICT (contents)': 'contents',
+ 'CONFLICT (binary)': 'binary',
+ 'CONFLICT (modify/delete)': 'modifyDelete',
+ 'CONFLICT (rename/delete)': 'renameDelete'
+};
+
+// Git files an add/add conflict under `contents`; only the message says so.
+function messageKind(type: string, message: string): MergeMessageKind {
+ if (message.startsWith('CONFLICT (add/add)')) {
+ return 'addAdd';
+ }
+ if (!type.startsWith(CONFLICT_PREFIX)) {
+ return 'informational';
+ }
+ return CONFLICT_KINDS[type] ?? 'otherConflict';
+}
+
+/**
+ * The NUL-separated fields of `merge-tree --write-tree -z`: the tree, a stage entry per conflicted
+ * path, a blank, then each message as a count, that many paths, its type and its text; none where a field cannot be read.
+ */
+export function parseMergeTree(fields: readonly string[]): MergeTreeOutput | undefined {
+ const tree = TreeId.fromGit(fields[0].trim());
+ if (tree === undefined) {
+ return undefined;
+ }
+ const stagesEnd = fields.indexOf('', 1);
+ const stages: StageEntry[] = [];
+ for (const field of fields.slice(1, stagesEnd === -1 ? undefined : stagesEnd)) {
+ const entry = parseStageEntry(field);
+ if (entry === undefined) {
+ return undefined;
+ }
+ stages.push(entry);
+ }
+ const messages: MergeMessage[] = [];
+ for (let index = stagesEnd === -1 ? fields.length : stagesEnd + 1; index < fields.length && fields[index] !== ''; ) {
+ const count = Number(fields[index]);
+ const paths: RelativeFilePath[] = [];
+ for (const field of fields.slice(index + 1, index + 1 + count)) {
+ const path = RelativeFilePath.fromGit(field);
+ if (path === undefined) {
+ return undefined;
+ }
+ paths.push(path);
+ }
+ const type = fields[index + 1 + count];
+ const message = fields[index + 2 + count].trim();
+ messages.push({ paths, kind: messageKind(type, message), message });
+ index += 3 + count;
+ }
+ return { tree, stages, messages };
+}
diff --git a/src/types/git/tree-entry.ts b/src/types/git/tree-entry.ts
new file mode 100644
index 000000000..e01f5d0a5
--- /dev/null
+++ b/src/types/git/tree-entry.ts
@@ -0,0 +1,5 @@
+import { RelativeFilePath } from '../file/relativeFilePath.js';
+import { BlobId } from './blob-id.js';
+
+/** One line of `ls-tree -r`: what `path` holds in a tree, and the id of the object holding it. */
+export type TreeEntry = { mode: string; type: 'blob' | 'tree' | 'commit'; oid: BlobId; path: RelativeFilePath };
diff --git a/src/types/sdk-context.ts b/src/types/sdk-context.ts
deleted file mode 100644
index d0dc30b72..000000000
--- a/src/types/sdk-context.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { FileService } from '../infrastructure/file-service.js';
-import { DirectoryPath } from './file/directoryPath.js';
-
-/** The directory an SDK is generated into: the `--destination` itself, or `sdk/` beside the source. */
-export class SdkContext {
- private readonly fileService = new FileService();
-
- constructor(private readonly sdkDirectory: DirectoryPath) {}
-
- public async exists(): Promise {
- return !(await this.fileService.directoryEmpty(this.sdkDirectory));
- }
-
- /** Replaces whatever the directory holds with the generated SDK. */
- public async save(generated: DirectoryPath): Promise {
- await this.fileService.cleanDirectory(this.sdkDirectory);
- await this.fileService.copyDirectoryContents(generated, this.sdkDirectory);
- return this.sdkDirectory;
- }
-}
diff --git a/src/types/sdk/file-content.ts b/src/types/sdk/file-content.ts
new file mode 100644
index 000000000..2e49a8708
--- /dev/null
+++ b/src/types/sdk/file-content.ts
@@ -0,0 +1,19 @@
+import { Buffer } from 'node:buffer';
+
+// Git's own test: a NUL in the first 8000 bytes makes a file binary, and a binary file is never merged line by line.
+export const isBinary = (content: Uint8Array): boolean => content.subarray(0, 8000).includes(0);
+
+/** A file whose line endings are never changed: a binary one, or one that already carries a CR. */
+export const keepsLineEndings = (content: Uint8Array): boolean => content.includes(0x0d) || isBinary(content);
+
+// One byte is one character and back, whatever the file's encoding, so only the line endings can change.
+const asText = (content: Uint8Array): string => Buffer.from(content).toString('latin1');
+const asBytes = (text: string): Uint8Array => new Uint8Array(Buffer.from(text, 'latin1'));
+
+/** `content` written the way `sample` ends its lines: CRLF where the sample uses it, else as it is. */
+export function lineEndingFor(content: Uint8Array, sample: Uint8Array | undefined): Uint8Array {
+ if (sample === undefined || keepsLineEndings(content) || !asText(sample).includes('\r\n')) {
+ return content;
+ }
+ return asBytes(asText(content).replace(/\n/g, '\r\n'));
+}
diff --git a/src/types/sdk/generation-record.ts b/src/types/sdk/generation-record.ts
new file mode 100644
index 000000000..b0d7b45c1
--- /dev/null
+++ b/src/types/sdk/generation-record.ts
@@ -0,0 +1,69 @@
+import { isJsonObject } from '../../utils/json-utils.js';
+import { CommitId } from '../git/commit-id.js';
+import { Language, Stability } from './generate.js';
+
+const SCHEMA_VERSION = 1;
+
+const isOneOf = (values: readonly T[], value: unknown): value is T =>
+ typeof value === 'string' && (values as readonly string[]).includes(value);
+
+/** What a recording wrote: the generation the SDK directory was written from, and the pristine snapshot of it. */
+type Recorded = { language: Language; stability: Stability; pristine: CommitId };
+
+/**
+ * What `.apimatic/generation.json` records: which generation the SDK directory was last written
+ * from, and the pristine snapshot of it, so a later run for the same generation has its base. Or
+ * none: no record, or one a CLI did not write.
+ */
+export class GenerationRecord {
+ private static readonly NONE = new GenerationRecord(undefined);
+
+ private constructor(private readonly found: Recorded | undefined) {}
+
+ /** From the file's contents; none for anything a CLI did not write. */
+ public static parse(json: string): GenerationRecord {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(json);
+ } catch {
+ return GenerationRecord.NONE;
+ }
+ if (!isJsonObject(parsed)) {
+ return GenerationRecord.NONE;
+ }
+ const record = parsed;
+ const pristine = typeof record.pristine === 'string' ? CommitId.fromGit(record.pristine) : undefined;
+ if (
+ record.schemaVersion !== SCHEMA_VERSION ||
+ !isOneOf(Object.values(Language), record.language) ||
+ !isOneOf(Object.values(Stability), record.stability) ||
+ pristine === undefined
+ ) {
+ return GenerationRecord.NONE;
+ }
+ return new GenerationRecord({ language: record.language, stability: record.stability, pristine });
+ }
+
+ /** No record: the directory holds none, or the run did not read one. */
+ public static none(): GenerationRecord {
+ return GenerationRecord.NONE;
+ }
+
+ public isFound(): boolean {
+ return this.found !== undefined;
+ }
+
+ /** The snapshot a run for `language` at `stability` starts from; none when the record is for another generation, or there is none. */
+ public baseFor(language: Language, stability: Stability): CommitId | undefined {
+ return this.found?.language === language && this.found.stability === stability ? this.found.pristine : undefined;
+ }
+}
+
+/** The file as a run that just recorded `pristine` for `language` at `stability` writes it: the fields in a fixed order, so a committed record diffs only when something changed. */
+export function recordContents(language: Language, stability: Stability, pristine: CommitId): string {
+ return `${JSON.stringify(
+ { schemaVersion: SCHEMA_VERSION, language, stability, pristine: pristine.toString() },
+ null,
+ 2
+ )}\n`;
+}
diff --git a/src/types/sdk/merge-plan.ts b/src/types/sdk/merge-plan.ts
new file mode 100644
index 000000000..a55d331ce
--- /dev/null
+++ b/src/types/sdk/merge-plan.ts
@@ -0,0 +1,181 @@
+import { ValueMap, ValueSet } from '../common/value-map.js';
+import { RelativeFilePath } from '../file/relativeFilePath.js';
+import { Change, MergeMessageKind, MergeTreeOutput, STAGE } from '../git/merge-tree.js';
+
+/** The merge runs with your tree as git's "ours" and the generation as its "theirs". */
+const SIDE_STAGE = { yours: STAGE.ours, generated: STAGE.theirs } as const;
+
+export type MergeInputs = {
+ merge: MergeTreeOutput;
+ /** Base to the disk: what you changed. Renames paired. */
+ yours: readonly Change[];
+ /** Base to the fresh generation: what the generator changed. Renames paired. */
+ generated: readonly Change[];
+ /** The disk to the merged tree, no renames: what the run writes, each as the merged tree holds it, and removes. */
+ writes: readonly FileWrite[];
+ deletes: readonly RelativeFilePath[];
+ /** Every path the base holds. */
+ basePaths: readonly RelativeFilePath[];
+};
+
+export type ConflictKind =
+ /** You and the generator changed the same lines. */
+ | 'bothChanged'
+ /** You and the generator both added the path with different content. */
+ | 'bothAdded'
+ /** You deleted the file and the generator changed it. */
+ | 'deletedByYou'
+ /** You edited the file and the generator no longer produces it. */
+ | 'noLongerGenerated'
+ /** You and the generator both changed a binary file. */
+ | 'binary'
+ /** A shape git reports that fits none of the above, such as both sides renaming one file differently. */
+ | 'other';
+
+/** A file to write, with its content as the merged tree holds it. */
+export type FileWrite = { path: RelativeFilePath; content: Uint8Array };
+
+export type Conflict = { path: RelativeFilePath; kind: ConflictKind };
+
+/** Every path the run touched or left alone, by what happened to it; conflicted paths are listed under `conflicts` instead. */
+export type MergeFiles = {
+ /** Files the generator produced for the first time. */
+ added: readonly RelativeFilePath[];
+ /** Unedited files replaced by their new version, or moved to the generator's new name. */
+ updated: readonly RelativeFilePath[];
+ /** Edited files the generator also changed, merged without conflict. */
+ merged: readonly RelativeFilePath[];
+ /** Unedited files the generator stopped producing. */
+ removed: readonly RelativeFilePath[];
+ /** Files you edited or added that the generator left alone. */
+ kept: readonly RelativeFilePath[];
+};
+
+/** The stronger of two accounts of one path, when git reports it more than once. */
+const RANK: Record = {
+ binary: 5,
+ bothAdded: 4,
+ deletedByYou: 3,
+ noLongerGenerated: 3,
+ bothChanged: 2,
+ other: 1
+};
+
+/** What a conflict git reported means here; a deletion is yours or the generator's by which side still has the file. */
+function kindOf(reported: Exclude, stages: ReadonlySet): ConflictKind {
+ switch (reported) {
+ case 'binary':
+ return 'binary';
+ case 'addAdd':
+ return 'bothAdded';
+ case 'modifyDelete':
+ case 'renameDelete':
+ return stages.has(SIDE_STAGE.yours) ? 'noLongerGenerated' : 'deletedByYou';
+ case 'contents':
+ return 'bothChanged';
+ case 'otherConflict':
+ return 'other';
+ }
+}
+
+function conflictsOf(merge: MergeTreeOutput): Conflict[] {
+ const stagesByPath = new ValueMap>();
+ for (const entry of merge.stages) {
+ stagesByPath.set(entry.path, (stagesByPath.get(entry.path) ?? new Set()).add(entry.stage));
+ }
+ const byPath = new ValueMap();
+ for (const { paths, kind: reported } of merge.messages) {
+ if (reported === 'informational') {
+ continue;
+ }
+ for (const path of paths) {
+ const kind = kindOf(reported, stagesByPath.get(path) ?? new Set());
+ const known = byPath.get(path);
+ if (known === undefined || RANK[kind] > RANK[known.kind]) {
+ byPath.set(path, { path, kind });
+ }
+ }
+ }
+ return byPath.values();
+}
+
+/** Each change under every path it names, so a rename is found by either name. */
+const changedPaths = (changes: readonly Change[]): ValueMap =>
+ new ValueMap(
+ changes.flatMap((change): [RelativeFilePath, Change][] =>
+ 'from' in change
+ ? [
+ [change.from, change],
+ [change.path, change]
+ ]
+ : [[change.path, change]]
+ )
+ );
+
+/** What the summary reports: every path that is not in conflict, by what happened to it. */
+function filesOf(
+ inputs: MergeInputs,
+ conflicted: ValueSet,
+ yours: ValueMap,
+ generated: ValueMap
+): MergeFiles {
+ const base = new ValueSet(inputs.basePaths);
+ const files: Record = {
+ added: [],
+ updated: [],
+ merged: [],
+ removed: [],
+ kept: []
+ };
+ for (const path of inputs.deletes) {
+ if (!conflicted.has(path)) {
+ files.removed.push(path);
+ }
+ }
+ for (const { path } of inputs.writes) {
+ if (conflicted.has(path)) {
+ continue;
+ }
+ if (!base.has(path) && generated.get(path)?.status === 'A') {
+ files.added.push(path);
+ } else if (yours.get(path)?.status === 'M' && generated.has(path)) {
+ files.merged.push(path);
+ } else {
+ files.updated.push(path);
+ }
+ }
+ for (const [path, change] of yours) {
+ if ((change.status === 'M' || change.status === 'A') && !generated.has(path) && !conflicted.has(path)) {
+ files.kept.push(change.path);
+ }
+ }
+ return files;
+}
+
+/** What a run writes and removes below the SDK directory; a path the plan does not mention is left as it is. */
+export class MergePlan {
+ constructor(
+ public readonly writes: readonly FileWrite[],
+ public readonly deletes: readonly RelativeFilePath[],
+ public readonly conflicts: readonly Conflict[],
+ public readonly files: MergeFiles
+ ) {}
+
+ /** What git's merge means for the SDK directory: writes, deletions, conflicts, and the summary. */
+ public static from(inputs: MergeInputs): MergePlan {
+ const conflicts = conflictsOf(inputs.merge);
+ const conflicted = new ValueSet(conflicts.map((conflict) => conflict.path));
+ const yours = changedPaths(inputs.yours);
+ const generated = changedPaths(inputs.generated);
+ return new MergePlan(inputs.writes, inputs.deletes, conflicts, filesOf(inputs, conflicted, yours, generated));
+ }
+
+ public hasConflicts(): boolean {
+ return this.conflicts.length > 0;
+ }
+
+ /** Whether the run would write nothing and remove nothing. */
+ public isEmpty(): boolean {
+ return this.writes.length === 0 && this.deletes.length === 0;
+ }
+}
diff --git a/src/types/sdk/write-problem.ts b/src/types/sdk/write-problem.ts
new file mode 100644
index 000000000..d86d7ddcb
--- /dev/null
+++ b/src/types/sdk/write-problem.ts
@@ -0,0 +1,4 @@
+import { RelativeFilePath } from '../file/relativeFilePath.js';
+
+/** The step a write failed at, in the file system's words; the write undoes nothing, so the caller puts the directory back. */
+export type WriteProblem = { file: RelativeFilePath; operation: 'write' | 'remove'; reason: string };
diff --git a/test/actions/sdk/generate-destination.test.ts b/test/actions/sdk/generate-destination.test.ts
index bbd495304..d6b6fa19b 100644
--- a/test/actions/sdk/generate-destination.test.ts
+++ b/test/actions/sdk/generate-destination.test.ts
@@ -11,9 +11,11 @@ import { canonical } from '../../../src/infrastructure/tmp-extensions.js';
import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js';
import { CommandMetadata } from '../../../src/types/common/command-metadata.js';
import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
+import { CommitId } from '../../../src/types/git/commit-id.js';
import { GitVersion } from '../../../src/types/git/git-version.js';
import { ProjectContext } from '../../../src/types/project-context.js';
import { Language, Stability } from '../../../src/types/sdk/generate.js';
+import { GenerationRecord, recordContents } from '../../../src/types/sdk/generation-record.js';
import { Files, stubSdkGeneration, writeFiles } from '../../helpers/sdk-generation.js';
import { describeCommit, snapshotFiles } from '../../helpers/snapshots.js';
@@ -21,6 +23,7 @@ const COMMAND_METADATA: CommandMetadata = { commandName: 'sdk generate', shell:
const GENERATED: Files = { 'README.md': '# sdk' };
/** Where the SDK directory's own repository keeps each generation as the server returned it. */
const PRISTINE_REF = 'refs/apimatic/pristine';
+const RECORD = '.apimatic/generation.json';
/** How the SDK directory is written: decided before the upload, from git and what the directory holds. */
describe('GenerateAction (sdk) — the destination', () => {
@@ -36,16 +39,21 @@ describe('GenerateAction (sdk) — the destination', () => {
const entries = () => fs.readdirSync(sdk().toString()).sort();
/** The commit the pristine ref names in the SDK directory's own repository, or none. */
const pristine = async () => (await git.resolveRef(sdk(), PRISTINE_REF))._unsafeUnwrap();
+ /** The record the last run left in the SDK directory. */
+ const record = () => GenerationRecord.parse(read(RECORD));
const refsIn = async (cwd: string) => (await execa('git', ['for-each-ref', 'refs/apimatic'], { cwd })).stdout;
const gitInitSteps = () => SdkGeneratePrompts.prototype.gitInitSteps as sinon.SinonStub;
+ const repositoryFound = () => SdkGeneratePrompts.prototype.repositoryFound as sinon.SinonStub;
- const execute = async (options: { force?: boolean; canAsk?: boolean; gitInit?: boolean; files?: Files } = {}) => {
+ const execute = async (
+ options: { force?: boolean; canAsk?: boolean; gitInit?: boolean; stability?: Stability; files?: Files } = {}
+ ) => {
await generates(options.files ?? GENERATED);
return new GenerateAction(new DirectoryPath(root), COMMAND_METADATA, 'auth-key').execute(
ProjectContext.in(new DirectoryPath(root)),
sdk(),
Language.PYTHON,
- Stability.STABLE,
+ options.stability ?? Stability.STABLE,
options.force ?? true,
options.canAsk ?? false,
options.gitInit ?? false
@@ -58,11 +66,18 @@ describe('GenerateAction (sdk) — the destination', () => {
(await git.init(sdk()))._unsafeUnwrap();
};
+ /** The SDK directory as a repository of its own with one generation recorded in it, as a run with --git-init leaves it. */
+ const recorded = async (files: Files = GENERATED) => {
+ expect((await execute({ gitInit: true, files })).isSuccess()).to.be.true;
+ return (await pristine())!;
+ };
+
beforeEach(() => {
root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-destination-')));
({ generates, generateSdk } = stubSdkGeneration(root));
sinon.stub(SdkGeneratePrompts.prototype, 'sdkGenerated');
sinon.stub(SdkGeneratePrompts.prototype, 'repositoryInitialised');
+ sinon.stub(SdkGeneratePrompts.prototype, 'repositoryFound');
sinon.stub(SdkGeneratePrompts.prototype, 'gitInitSteps');
});
@@ -101,14 +116,14 @@ describe('GenerateAction (sdk) — the destination', () => {
expect(gitInitSteps().called).to.be.false;
});
- it(`with ${found}, warns and replaces the SDK directory's own repository, its .git included`, async () => {
+ it(`with ${found}, warns and overwrites the SDK directory's own repository, keeping its .git`, async () => {
await ownRepository();
const warned = unavailable();
expect((await execute()).isSuccess()).to.be.true;
expect(warned.calledOnce).to.be.true;
- expect(entries()).to.deep.equal(['README.md']);
+ expect(entries()).to.deep.equal(['.git', 'README.md']);
});
}
});
@@ -139,10 +154,12 @@ describe('GenerateAction (sdk) — the destination', () => {
expect((await execute({ force, canAsk, gitInit: true })).isSuccess()).to.be.true;
expect(repositoryInitialised.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(repositoryFound().called).to.be.false;
expect(fs.readFileSync(at('.git/HEAD'), 'utf-8').trim()).to.equal('ref: refs/heads/main');
- const recorded = await pristine();
- expect(recorded).to.not.be.undefined;
- expect([...(await snapshotFiles(sdk(), recorded!)).keys()]).to.deep.equal(['README.md']);
+ const snapshot = await pristine();
+ expect(snapshot).to.not.be.undefined;
+ expect([...(await snapshotFiles(sdk(), snapshot!)).keys()]).to.deep.equal(['README.md']);
+ expect(record().baseFor(Language.PYTHON, Stability.STABLE)).to.deep.equal(snapshot);
});
it(`writes the SDK without a repository otherwise, then gives the steps that keep edits, ${how}`, async () => {
@@ -172,34 +189,71 @@ describe('GenerateAction (sdk) — the destination', () => {
expect(await pristine()).to.not.be.undefined;
expect(await refsIn(root)).to.equal('');
});
+
+ it('fails with its own message, writing nothing, when the SDK directory cannot be made a repository', async () => {
+ sinon.stub(GitService.prototype, 'init').resolves(err('git broke'));
+ const notInitialised = sinon.stub(SdkGeneratePrompts.prototype, 'repositoryNotInitialised');
+
+ expect((await execute({ gitInit: true })).isFailed()).to.be.true;
+
+ expect(fs.existsSync(at('README.md'))).to.be.false;
+ expect(notInitialised.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(notInitialised.firstCall.args[1]).to.equal('git broke');
+ });
});
describe('when the SDK directory is a repository of its own', () => {
- it('records into it, made afresh after the save, and keeps what was there out of the snapshot', async () => {
+ it('records into it, saying it found it, and keeps what was there out of the snapshot', async () => {
await ownRepository();
write('old.py', 'old');
const repositoryInitialised = SdkGeneratePrompts.prototype.repositoryInitialised as sinon.SinonStub;
expect((await execute()).isSuccess()).to.be.true;
- expect(repositoryInitialised.firstCall.args[0].isEqual(sdk())).to.be.true;
- expect(entries()).to.deep.equal(['.git', 'README.md']);
+ expect(repositoryInitialised.called).to.be.false;
+ expect(repositoryFound().firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(entries()).to.deep.equal(['.apimatic', '.git', 'README.md']);
expect([...(await snapshotFiles(sdk(), (await pristine())!)).keys()]).to.deep.equal(['README.md']);
expect(gitInitSteps().called).to.be.false;
});
- // Until a regeneration merges, the save remakes the repository, so each run's snapshot starts a chain of its own.
- it('starts the snapshots over on a later run', async () => {
- await ownRepository();
- expect((await execute()).isSuccess()).to.be.true;
- const first = (await pristine())!;
+ it('chains the next generation onto the last snapshot, so each run has the one before as its base, saying found only the first time', async () => {
+ const first = await recorded();
expect((await execute({ files: { 'README.md': '# sdk v2' } })).isSuccess()).to.be.true;
const second = (await pristine())!;
- expect(second).to.not.deep.equal(first);
- expect((await describeCommit(sdk(), second)).parents).to.deep.equal([]);
+ expect((await describeCommit(sdk(), second)).parents).to.deep.equal([first]);
expect(read('README.md')).to.equal('# sdk v2');
+ expect(record().baseFor(Language.PYTHON, Stability.STABLE)).to.deep.equal(second);
+ expect(repositoryFound().called).to.be.false;
+ });
+
+ it('records afresh over an SDK generated at another stability, without a word', async () => {
+ const first = await recorded();
+ const snapshotMissing = sinon.stub(SdkGeneratePrompts.prototype, 'snapshotMissing');
+
+ expect((await execute({ stability: Stability.BETA, files: { 'README.md': '# beta' } })).isSuccess()).to.be.true;
+
+ expect(repositoryFound().called).to.be.false;
+ expect(snapshotMissing.called).to.be.false;
+ expect(record().baseFor(Language.PYTHON, Stability.STABLE)).to.be.undefined;
+ expect((await describeCommit(sdk(), record().baseFor(Language.PYTHON, Stability.BETA)!)).parents).to.deep.equal([
+ first
+ ]);
+ });
+
+ it('warns when the recorded snapshot is not in the repository, then records afresh', async () => {
+ await recorded();
+ const missing = CommitId.fromGit('f'.repeat(40))!;
+ write(RECORD, recordContents(Language.PYTHON, Stability.STABLE, missing));
+ const snapshotMissing = sinon.stub(SdkGeneratePrompts.prototype, 'snapshotMissing');
+
+ expect((await execute()).isSuccess()).to.be.true;
+
+ expect(snapshotMissing.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(repositoryFound().called).to.be.false;
+ expect(record().baseFor(Language.PYTHON, Stability.STABLE)).to.deep.equal(await pristine());
});
it('still succeeds, with a warning, when the snapshot cannot be recorded', async () => {
@@ -213,29 +267,26 @@ describe('GenerateAction (sdk) — the destination', () => {
expect(snapshotNotRecorded.firstCall.args).to.deep.equal(['git broke']);
});
- it('reports a repository git could not inspect, before generating', async () => {
- await ownRepository();
- sinon.stub(GitService.prototype, 'topLevel').resolves(err('git broke'));
- const notInspected = sinon.stub(SdkGeneratePrompts.prototype, 'repositoryNotInspected');
-
- expect((await execute()).isFailed()).to.be.true;
-
- expect(notInspected.firstCall.args[0].isEqual(sdk())).to.be.true;
- expect(notInspected.firstCall.args[1]).to.equal('git broke');
- expect(generateSdk.called).to.be.false;
- });
- });
-
- it('still succeeds, with a warning and the SDK written, when the directory cannot be made a repository', async () => {
- sinon.stub(GitService.prototype, 'init').resolves(err('git broke'));
- const notInitialised = sinon.stub(SdkGeneratePrompts.prototype, 'repositoryNotInitialised');
+ for (const [what, prepare, breaks] of [
+ ['the repository', ownRepository, () => sinon.stub(GitService.prototype, 'topLevel').resolves(err('git broke'))],
+ [
+ 'the snapshot the record names',
+ recorded,
+ () => sinon.stub(GitService.prototype, 'hasCommit').resolves(err('git broke'))
+ ]
+ ] as const) {
+ it(`reports a git that will not say what ${what} is, before generating`, async () => {
+ await prepare();
+ breaks();
+ const notInspected = sinon.stub(SdkGeneratePrompts.prototype, 'repositoryNotInspected');
+ generateSdk.resetHistory();
- expect((await execute({ gitInit: true })).isSuccess()).to.be.true;
+ expect((await execute()).isFailed()).to.be.true;
- expect(read('README.md')).to.equal('# sdk');
- expect(fs.existsSync(at('.git'))).to.be.false;
- expect(notInitialised.firstCall.args[0].isEqual(sdk())).to.be.true;
- expect(notInitialised.firstCall.args[1]).to.equal('git broke');
- expect(gitInitSteps().called).to.be.false;
+ expect(notInspected.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(notInspected.firstCall.args[1]).to.equal('git broke');
+ expect(generateSdk.called).to.be.false;
+ });
+ }
});
});
diff --git a/test/actions/sdk/generate-merge.test.ts b/test/actions/sdk/generate-merge.test.ts
new file mode 100644
index 000000000..13a08af8a
--- /dev/null
+++ b/test/actions/sdk/generate-merge.test.ts
@@ -0,0 +1,267 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { expect } from 'chai';
+import { err } from 'neverthrow';
+import sinon from 'sinon';
+import { GenerateAction } from '../../../src/actions/sdk/generate.js';
+import { FileService } from '../../../src/infrastructure/file-service.js';
+import { GitService } from '../../../src/infrastructure/git-service.js';
+import { canonical } from '../../../src/infrastructure/tmp-extensions.js';
+import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js';
+import { CommandMetadata } from '../../../src/types/common/command-metadata.js';
+import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
+import { CommitId } from '../../../src/types/git/commit-id.js';
+import { GitContext } from '../../../src/types/git-context.js';
+import { ProjectContext } from '../../../src/types/project-context.js';
+import { Language, Stability } from '../../../src/types/sdk/generate.js';
+import { GenerationRecord, recordContents } from '../../../src/types/sdk/generation-record.js';
+import { Files, stubSdkGeneration, writeFiles } from '../../helpers/sdk-generation.js';
+import { describeCommit } from '../../helpers/snapshots.js';
+
+const COMMAND_METADATA: CommandMetadata = { commandName: 'sdk generate', shell: 'test' };
+/** Where the SDK directory's own repository keeps each generation as the server returned it. */
+const PRISTINE_REF = 'refs/apimatic/pristine';
+/** The ref a merge keeps the directory under before writing, byte for byte. */
+const PREVIOUS_REF = 'refs/apimatic/previous';
+const RECORD = '.apimatic/generation.json';
+
+const MODEL_V1 = [
+ 'class Book:',
+ ' id: str',
+ ' title: str',
+ ' author: str',
+ ' year: int',
+ ' # Price in USD.',
+ ' price: float',
+ ''
+].join('\n');
+const MODEL_V2 = MODEL_V1.replace('# Price in USD.', '# Retail price in US dollars.') + ' isbn: str\n';
+const DECORATED = 'from dataclasses import dataclass\n\n\n@dataclass\n';
+const MODEL_V1_EDITED = MODEL_V1.replace('# Price in USD.', '# Price in USD, VAT included.');
+
+const V1: Files = { 'README.md': '# sdk', 'models/book.py': MODEL_V1 };
+const V2: Files = { 'README.md': '# sdk v2', 'models/book.py': MODEL_V2, 'models/author.py': 'class Author: ...' };
+
+/** A run into a repository holding a record for this generation merges the fresh output over the directory as it is. */
+describe('GenerateAction (sdk) — a regeneration merges', () => {
+ let root: string;
+ let generates: (files: Files) => Promise;
+ let generateSdk: sinon.SinonStub;
+ const git = new GitService();
+
+ const sdk = () => new DirectoryPath(path.join(root, 'sdk', 'python'));
+ const at = (relative: string) => path.join(sdk().toString(), relative);
+ const read = (relative: string) => fs.readFileSync(at(relative), 'utf8');
+ const write = (files: Files) => writeFiles(sdk().toString(), files);
+ const pristine = async () => (await git.resolveRef(sdk(), PRISTINE_REF))._unsafeUnwrap();
+ const record = () => GenerationRecord.parse(read(RECORD));
+ const base = () => record().baseFor(Language.PYTHON, Stability.STABLE);
+ const sdkMerged = () => SdkGeneratePrompts.prototype.sdkMerged as sinon.SinonStub;
+ const listed = (paths: readonly { toString(): string }[]) => paths.map(String);
+
+ const execute = async (
+ files: Files,
+ options: { force?: boolean; canAsk?: boolean; gitInit?: boolean; stability?: Stability } = {}
+ ) => {
+ await generates(files);
+ return new GenerateAction(new DirectoryPath(root), COMMAND_METADATA, 'auth-key').execute(
+ ProjectContext.in(new DirectoryPath(root)),
+ sdk(),
+ Language.PYTHON,
+ options.stability ?? Stability.STABLE,
+ options.force ?? false,
+ options.canAsk ?? true,
+ options.gitInit ?? false
+ );
+ };
+
+ /** A first generation written and recorded into the SDK directory's own repository, as a run with --git-init leaves it. */
+ const recorded = async (files: Files = V1): Promise => {
+ expect((await execute(files, { gitInit: true })).isSuccess()).to.be.true;
+ return (await pristine())!;
+ };
+
+ beforeEach(() => {
+ root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-merge-')));
+ ({ generates, generateSdk } = stubSdkGeneration(root));
+ sinon.stub(SdkGeneratePrompts.prototype, 'sdkGenerated');
+ sinon.stub(SdkGeneratePrompts.prototype, 'repositoryInitialised');
+ sinon.stub(SdkGeneratePrompts.prototype, 'repositoryFound');
+ sinon.stub(SdkGeneratePrompts.prototype, 'sdkMerged');
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('merges the generator’s changes into your edited files, adds new files and keeps your own, without asking', async () => {
+ const first = await recorded();
+ write({ 'models/book.py': DECORATED + MODEL_V1, 'notes.txt': 'mine' });
+ const overwriteSdk = sinon.stub(SdkGeneratePrompts.prototype, 'overwriteSdk');
+
+ expect((await execute(V2)).isSuccess()).to.be.true;
+
+ expect(overwriteSdk.called).to.be.false;
+ expect(read('models/book.py')).to.equal(DECORATED + MODEL_V2);
+ expect(read('README.md')).to.equal('# sdk v2');
+ expect(read('models/author.py')).to.equal('class Author: ...');
+ expect(read('notes.txt')).to.equal('mine');
+ const files = sdkMerged().firstCall.args[1];
+ expect(listed(files.added)).to.deep.equal(['models/author.py']);
+ expect(listed(files.updated)).to.deep.equal(['README.md']);
+ expect(listed(files.merged)).to.deep.equal(['models/book.py']);
+ expect(listed(files.removed)).to.deep.equal([]);
+ expect(listed(files.kept)).to.deep.equal(['notes.txt']);
+ expect(base()).to.deep.equal(await pristine());
+ expect((await describeCommit(sdk(), base()!)).parents).to.deep.equal([first]);
+ });
+
+ it('refuses conflicts, naming them, and writes nothing', async () => {
+ const first = await recorded();
+ write({ 'models/book.py': MODEL_V1_EDITED });
+ const conflictsNotAsked = sinon.stub(SdkGeneratePrompts.prototype, 'conflictsNotAsked');
+
+ expect((await execute(V2)).isFailed()).to.be.true;
+
+ const [directory, conflicts] = conflictsNotAsked.firstCall.args;
+ expect(conflicts.map((conflict) => [conflict.path.toString(), conflict.kind])).to.deep.equal([
+ ['models/book.py', 'bothChanged']
+ ]);
+ expect(directory.isEqual(sdk())).to.be.true;
+ expect(read('models/book.py')).to.equal(MODEL_V1_EDITED);
+ expect(read('README.md')).to.equal('# sdk');
+ expect(fs.existsSync(at('models/author.py'))).to.be.false;
+ expect(base()).to.deep.equal(first);
+ expect(sdkMerged().called).to.be.false;
+ });
+
+ for (const [what, prepare] of [
+ ['another stability', async () => ({ stability: Stability.BETA })],
+ [
+ 'a snapshot the repository does not hold',
+ async () => {
+ write({
+ [RECORD]: recordContents(Language.PYTHON, Stability.STABLE, CommitId.fromGit('f'.repeat(40))!)
+ });
+ return {};
+ }
+ ]
+ ] as const) {
+ it(`replaces, with --force, an SDK recorded for ${what}, warning, and records it for the next run`, async () => {
+ await recorded();
+ write({ 'README.md': '# edited' });
+ sinon.stub(SdkGeneratePrompts.prototype, 'snapshotMissing');
+ const replaced = sinon.stub(SdkGeneratePrompts.prototype, 'recordedSdkReplaced');
+ const options: { stability?: Stability } = await prepare();
+
+ expect((await execute(V2, { ...options, force: true })).isSuccess()).to.be.true;
+
+ expect(replaced.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(read('README.md')).to.equal('# sdk v2');
+ expect(record().baseFor(Language.PYTHON, options.stability ?? Stability.STABLE)).to.deep.equal(await pristine());
+ expect(sdkMerged().called).to.be.false;
+ });
+ }
+
+ it('asks before replacing a recorded SDK it cannot merge into, and does not warn when the developer agrees', async () => {
+ await recorded();
+ const overwriteSdk = sinon.stub(SdkGeneratePrompts.prototype, 'overwriteSdk').resolves(true);
+ const replaced = sinon.stub(SdkGeneratePrompts.prototype, 'recordedSdkReplaced');
+
+ expect((await execute(V2, { stability: Stability.BETA })).isSuccess()).to.be.true;
+
+ expect(overwriteSdk.calledOnce).to.be.true;
+ expect(replaced.called).to.be.false;
+ expect(read('README.md')).to.equal('# sdk v2');
+ });
+
+ it('fails with its own message, writing nothing, when git cannot work out the merge', async () => {
+ await recorded();
+ write({ 'notes.txt': 'mine' });
+ sinon.stub(GitService.prototype, 'mergeTree').resolves(err('git broke'));
+ const customCodeFailed = sinon.stub(SdkGeneratePrompts.prototype, 'customCodeFailed');
+
+ expect((await execute(V2)).isFailed()).to.be.true;
+
+ expect(customCodeFailed.firstCall.args).to.deep.equal(['git broke']);
+ expect(read('README.md')).to.equal('# sdk');
+ expect(fs.existsSync(at('models/author.py'))).to.be.false;
+ });
+
+ it('fails with its own message, writing nothing, when the directory cannot be kept before the write', async () => {
+ const first = await recorded();
+ write({ 'notes.txt': 'mine' });
+ const real = GitService.prototype.updateRef;
+ sinon.stub(GitService.prototype, 'updateRef').callsFake(function (this: GitService, root, ref, commit) {
+ return ref === PREVIOUS_REF ? Promise.resolve(err('git broke')) : real.call(this, root, ref, commit);
+ });
+ const customCodeFailed = sinon.stub(SdkGeneratePrompts.prototype, 'customCodeFailed');
+
+ expect((await execute(V2)).isFailed()).to.be.true;
+
+ expect(customCodeFailed.firstCall.args).to.deep.equal(['git broke']);
+ expect(read('README.md')).to.equal('# sdk');
+ expect(read('notes.txt')).to.equal('mine');
+ expect(fs.existsSync(at('models/author.py'))).to.be.false;
+ expect(base()).to.deep.equal(first);
+ });
+
+ it('puts the directory back and names the file when a write fails partway', async () => {
+ const first = await recorded();
+ const real = FileService.prototype.writeBytes;
+ sinon.stub(FileService.prototype, 'writeBytes').callsFake(function (this: FileService, file, content) {
+ return file.toString().endsWith('book.py')
+ ? Promise.reject(new Error('EACCES: permission denied'))
+ : real.call(this, file, content);
+ });
+ const sdkNotWritten = sinon.stub(SdkGeneratePrompts.prototype, 'sdkNotWritten');
+ const sdkRestored = sinon.stub(SdkGeneratePrompts.prototype, 'sdkRestored');
+
+ expect((await execute(V2)).isFailed()).to.be.true;
+
+ const [directory, problem] = sdkNotWritten.firstCall.args;
+ expect(directory.isEqual(sdk())).to.be.true;
+ expect(problem.file.toString()).to.equal('models/book.py');
+ expect(problem.operation).to.equal('write');
+ expect(problem.reason).to.contain('EACCES');
+ expect(sdkRestored.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(read('README.md')).to.equal('# sdk');
+ expect(read('models/book.py')).to.equal(MODEL_V1);
+ expect(fs.existsSync(at('models/author.py'))).to.be.false;
+ expect(base()).to.deep.equal(first);
+ expect(generateSdk.calledTwice).to.be.true;
+ });
+
+ it('gives the restore command when the directory cannot be put back either', async () => {
+ await recorded();
+ sinon.stub(FileService.prototype, 'writeBytes').rejects(new Error('EACCES: permission denied'));
+ sinon.stub(GitContext.prototype, 'restore').resolves(err('EBUSY: resource busy'));
+ sinon.stub(SdkGeneratePrompts.prototype, 'sdkNotWritten');
+ const sdkNotRestored = sinon.stub(SdkGeneratePrompts.prototype, 'sdkNotRestored');
+
+ expect((await execute(V2)).isFailed()).to.be.true;
+
+ const [directory, previous, reason] = sdkNotRestored.firstCall.args;
+ expect(directory.isEqual(sdk())).to.be.true;
+ expect(previous).to.deep.equal((await git.resolveRef(sdk(), PREVIOUS_REF))._unsafeUnwrap());
+ expect(reason).to.equal('EBUSY: resource busy');
+ });
+
+ it('warns, keeping the old record, when the new snapshot cannot be recorded after a merge', async () => {
+ const first = await recorded();
+ const real = GitService.prototype.updateRef;
+ sinon.stub(GitService.prototype, 'updateRef').callsFake(function (this: GitService, root, ref, commit) {
+ return ref === PRISTINE_REF ? Promise.resolve(err('git broke')) : real.call(this, root, ref, commit);
+ });
+ const notRecorded = sinon.stub(SdkGeneratePrompts.prototype, 'snapshotNotRecordedAfterMerge');
+
+ expect((await execute(V2)).isSuccess()).to.be.true;
+
+ expect(notRecorded.firstCall.args).to.deep.equal(['git broke']);
+ expect(read('README.md')).to.equal('# sdk v2');
+ expect(base()).to.deep.equal(first);
+ expect(sdkMerged().calledOnce).to.be.true;
+ });
+});
diff --git a/test/actions/sdk/generate.test.ts b/test/actions/sdk/generate.test.ts
index ccee7a24b..b98104dbd 100644
--- a/test/actions/sdk/generate.test.ts
+++ b/test/actions/sdk/generate.test.ts
@@ -5,6 +5,7 @@ import { expect } from 'chai';
import { err } from 'neverthrow';
import sinon from 'sinon';
import { GenerateAction } from '../../../src/actions/sdk/generate.js';
+import { GitService } from '../../../src/infrastructure/git-service.js';
import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js';
import { CommandMetadata } from '../../../src/types/common/command-metadata.js';
import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
@@ -143,5 +144,19 @@ describe('GenerateAction (sdk)', () => {
expect(overwriteSdk.called).to.be.false;
expect(read('README.md')).to.equal('# sdk');
});
+
+ it('is asked before replacing a repository of the developer’s own that holds only hidden files besides .git', async () => {
+ fs.rmSync(at('README.md'));
+ (await new GitService().init(sdk()))._unsafeUnwrap();
+ writeFiles(sdk().toString(), { '.github/workflows/publish.yml': 'on: push' });
+ const overwriteSdk = sinon.stub(SdkGeneratePrompts.prototype, 'overwriteSdk').resolves(false);
+ sinon.stub(SdkGeneratePrompts.prototype, 'destinationDirNotEmpty');
+
+ expect((await execute({ force: false, canAsk: true })).isCancelled()).to.be.true;
+
+ expect(overwriteSdk.calledOnce).to.be.true;
+ expect(read('.github/workflows/publish.yml')).to.equal('on: push');
+ expect(generateSdk.called).to.be.false;
+ });
});
});
diff --git a/test/infrastructure/file-service.test.ts b/test/infrastructure/file-service.test.ts
index 5bb2f8074..543d08459 100644
--- a/test/infrastructure/file-service.test.ts
+++ b/test/infrastructure/file-service.test.ts
@@ -101,6 +101,90 @@ describe('FileService', () => {
});
});
+ describe('hasEntriesExcluding', () => {
+ const fileService = new FileService();
+ let root: string;
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'file-service-'));
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('counts every entry but the named ones, hidden ones included, and none for a directory that is not there', async () => {
+ const directory = new DirectoryPath(path.join(root, 'sdk'));
+ const git = [new FileName('.git')];
+ expect(await fileService.hasEntriesExcluding(directory, git)).to.be.false;
+
+ fs.mkdirSync(path.join(root, 'sdk', '.git'), { recursive: true });
+ expect(await fileService.hasEntriesExcluding(directory, git)).to.be.false;
+
+ fs.mkdirSync(path.join(root, 'sdk', '.github'));
+ expect(await fileService.hasEntriesExcluding(directory, git)).to.be.true;
+ });
+ });
+
+ describe('writeBytesIfChanged', () => {
+ const fileService = new FileService();
+ let root: string;
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'file-service-'));
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('writes the bytes, making the directories above, and skips a file that already holds them', async () => {
+ const file = new FilePath(new DirectoryPath(path.join(root, 'deep', 'er')), new FileName('a.bin'));
+ const bytes = new Uint8Array([0x78, 0x0d, 0x00]);
+
+ expect(await fileService.writeBytesIfChanged(file, bytes)).to.be.true;
+ expect(await fileService.writeBytesIfChanged(file, bytes)).to.be.false;
+ expect(await fileService.writeBytesIfChanged(file, new Uint8Array([0x78]))).to.be.true;
+
+ expect([...fs.readFileSync(file.toString())]).to.deep.equal([0x78]);
+ });
+ });
+
+ describe('removeEmptyDirectories', () => {
+ const fileService = new FileService();
+ let root: string;
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'file-service-'));
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('removes the directory and each empty parent below the root, stopping at one that holds something', async () => {
+ const within = new DirectoryPath(path.join(root, 'sdk'));
+ fs.mkdirSync(path.join(root, 'sdk', 'models', 'old', 'deep'), { recursive: true });
+ fs.writeFileSync(path.join(root, 'sdk', 'models', 'keep.ts'), 'k');
+
+ await fileService.removeEmptyDirectories(within.join('models', 'old', 'deep'), within);
+
+ expect(fs.existsSync(path.join(root, 'sdk', 'models', 'old'))).to.be.false;
+ expect(fs.existsSync(path.join(root, 'sdk', 'models', 'keep.ts'))).to.be.true;
+ });
+
+ it('leaves the root itself, even when it is empty', async () => {
+ const within = new DirectoryPath(path.join(root, 'sdk'));
+ fs.mkdirSync(path.join(root, 'sdk', 'only'), { recursive: true });
+
+ await fileService.removeEmptyDirectories(within.join('only'), within);
+
+ expect(fs.existsSync(path.join(root, 'sdk'))).to.be.true;
+ expect(fs.readdirSync(path.join(root, 'sdk'))).to.deep.equal([]);
+ });
+ });
+
describe('spelledOnDisk', () => {
const fileService = new FileService();
let root: DirectoryPath;
diff --git a/test/infrastructure/git-service.test.ts b/test/infrastructure/git-service.test.ts
index aca7f360c..2e24c184f 100644
--- a/test/infrastructure/git-service.test.ts
+++ b/test/infrastructure/git-service.test.ts
@@ -1,3 +1,4 @@
+import { Buffer } from 'node:buffer';
import fs from 'fs';
import os from 'os';
import path from 'path';
@@ -6,10 +7,15 @@ import { execa } from 'execa';
import { GitService } from '../../src/infrastructure/git-service.js';
import { canonical } from '../../src/infrastructure/tmp-extensions.js';
import { DirectoryPath } from '../../src/types/file/directoryPath.js';
+import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js';
+import { CommitId } from '../../src/types/git/commit-id.js';
+import { TreeId } from '../../src/types/git/tree-id.js';
import { describeCommit, snapshotFiles } from '../helpers/snapshots.js';
const REF = 'refs/apimatic/pristine';
+const rel = (value: string) => RelativeFilePath.fromGit(value)!;
+
describe('GitService', () => {
let root: string;
let git: GitService;
@@ -27,6 +33,18 @@ describe('GitService', () => {
return directory;
};
+ /** Every file a tree holds, by path, as text. */
+ const filesOf = async (repo: DirectoryPath, tree: TreeId | CommitId) => {
+ const entries = (await git.listTree(repo, tree))._unsafeUnwrap();
+ const contents = (
+ await git.readBlobs(
+ repo,
+ entries.map((entry) => entry.oid)
+ )
+ )._unsafeUnwrap();
+ return new Map(entries.map((entry, index) => [entry.path.toString(), Buffer.from(contents[index]).toString()]));
+ };
+
beforeEach(() => {
root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'git-service-')));
git = new GitService();
@@ -125,7 +143,7 @@ describe('GitService', () => {
});
});
- it('commits a tree, moves a ref to it, and finds the commit and its tree again', async () => {
+ it('commits a tree, moves a ref to it, finds the commit and its tree again, and knows which commits it holds', async () => {
const repo = await repository();
write(repo, 'a.txt', 'a\n');
const tree = (await git.treeOf(repo, repo))._unsafeUnwrap();
@@ -142,5 +160,145 @@ describe('GitService', () => {
subject: 'second'
});
expect((await git.treeOfCommit(repo, first))._unsafeUnwrap().isEqual(tree)).to.be.true;
+ expect((await git.hasCommit(repo, first))._unsafeUnwrap()).to.be.true;
+ expect((await git.hasCommit(repo, CommitId.fromGit('f'.repeat(40))!))._unsafeUnwrap()).to.be.false;
+ });
+
+ describe('trees with line endings normalised', () => {
+ it('stores a CRLF file with LF, whatever the developer’s core.autocrlf says', async () => {
+ const repo = await repository();
+ write(repo, 'crlf.txt', 'one\r\ntwo\r\n');
+ write(repo, 'lf.txt', 'one\ntwo\n');
+
+ const files = await filesOf(repo, (await git.treeOf(repo, repo))._unsafeUnwrap());
+
+ expect(files.get('crlf.txt')).to.equal('one\ntwo\n');
+ expect(files.get('lf.txt')).to.equal('one\ntwo\n');
+ });
+
+ it('honours the repository’s ignore rules for its working tree, except for the paths told to include', async () => {
+ const repo = await repository();
+ write(repo, '.gitignore', 'dist/\n*.log\n');
+ write(repo, 'src/index.ts', 'export {};\n');
+ write(repo, 'dist/index.js', 'built\n');
+ write(repo, 'dist/generated.js', 'generated\n');
+ write(repo, 'debug.log', 'noise\n');
+
+ const tree = (await git.treeOfDirectory(repo, ['dist/generated.js', 'missing.txt'].map(rel)))._unsafeUnwrap();
+
+ expect([...(await filesOf(repo, tree)).keys()]).to.have.members([
+ '.gitignore',
+ 'src/index.ts',
+ 'dist/generated.js'
+ ]);
+ });
+
+ it('reads a blob byte for byte, a last one ending in CR whole', async () => {
+ const repo = await repository();
+ write(repo, 'cr-at-end.bin', 'x\r');
+ write(repo, 'a.txt', 'a\n');
+ const entries = (await git.listTree(repo, (await git.treeOf(repo, repo))._unsafeUnwrap()))._unsafeUnwrap();
+ const cr = entries.find((entry) => entry.path.toString() === 'cr-at-end.bin')!;
+
+ const contents = (await git.readBlobs(repo, [cr.oid]))._unsafeUnwrap();
+
+ expect([...contents[0]]).to.deep.equal([0x78, 0x0d]);
+ });
+ });
+
+ it('keeps every byte when asked for the working tree as it is', async () => {
+ const repo = await repository();
+ write(repo, 'crlf.txt', 'one\r\ntwo\r\n');
+
+ const files = await filesOf(repo, (await git.treeOfDirectoryAsIs(repo, []))._unsafeUnwrap());
+
+ expect(files.get('crlf.txt')).to.equal('one\r\ntwo\r\n');
+ });
+
+ it('diffs two trees, pairing a moved file only when asked to', async () => {
+ const repo = await repository();
+ const before = repo.join('before');
+ const after = repo.join('after');
+ write(before, 'a.txt', 'one\ntwo\nthree\n');
+ write(before, 'b.txt', 'b\n');
+ write(after, 'moved/a.txt', 'one\ntwo\nthree\n');
+ write(after, 'b.txt', 'B\n');
+ write(after, 'c.txt', 'c\n');
+ const from = (await git.treeOf(repo, before))._unsafeUnwrap();
+ const to = (await git.treeOf(repo, after))._unsafeUnwrap();
+
+ const paired = (await git.diffTreesPairingRenames(repo, from, to))._unsafeUnwrap();
+ const plain = (await git.diffTrees(repo, from, to))._unsafeUnwrap();
+
+ const byPath = (changes: typeof paired) =>
+ changes
+ .map((change) => [change.status, change.path.toString(), 'from' in change ? change.from.toString() : undefined])
+ .sort((x, y) => String(x[1]).localeCompare(String(y[1])));
+ expect(byPath(paired)).to.deep.equal([
+ ['M', 'b.txt', undefined],
+ ['A', 'c.txt', undefined],
+ ['R', 'moved/a.txt', 'a.txt']
+ ]);
+ expect(byPath(plain)).to.deep.equal([
+ ['D', 'a.txt', undefined],
+ ['M', 'b.txt', undefined],
+ ['A', 'c.txt', undefined],
+ ['A', 'moved/a.txt', undefined]
+ ]);
+ const modified = plain.find((change) => change.path.toString() === 'b.txt')!;
+ expect('blob' in modified && modified.blob.toString()).to.have.lengthOf(40);
+ });
+
+ describe('mergeTree', () => {
+ /** A commit in `repo` holding `files`, on `parent`. */
+ const commit = async (
+ repo: DirectoryPath,
+ name: string,
+ files: Record,
+ parent: CommitId | undefined
+ ) => {
+ const work = new DirectoryPath(path.join(root, name));
+ for (const [relative, contents] of Object.entries(files)) {
+ write(work, relative, contents);
+ }
+ return (await git.commitTree(repo, (await git.treeOf(repo, work))._unsafeUnwrap(), parent, name))._unsafeUnwrap();
+ };
+
+ it('reads the merged tree, the stages of each conflicted path and the messages', async () => {
+ const repo = await repository();
+ const base = await commit(repo, 'base', { 'same.txt': 'one\n', 'gone.txt': 'gone\n' }, undefined);
+ const ours = await commit(repo, 'ours', { 'same.txt': 'ours\n', 'gone.txt': 'kept\n' }, base);
+ const theirs = await commit(repo, 'theirs', { 'same.txt': 'theirs\n' }, base);
+
+ const merged = (await git.mergeTree(repo, ours, theirs))._unsafeUnwrap();
+
+ expect(merged.stages.map(({ stage, path: staged }) => [staged.toString(), stage])).to.have.deep.members([
+ ['same.txt', 1],
+ ['same.txt', 2],
+ ['same.txt', 3],
+ ['gone.txt', 1],
+ ['gone.txt', 2]
+ ]);
+ expect(merged.messages.map(({ paths, kind }) => [paths.map(String), kind])).to.have.deep.members([
+ [['same.txt'], 'informational'],
+ [['same.txt'], 'contents'],
+ [['gone.txt'], 'modifyDelete']
+ ]);
+ expect((await filesOf(repo, merged.tree)).get('gone.txt')).to.equal('kept\n');
+ });
+
+ it('reads a clean merge as the merged tree and nothing else', async () => {
+ const repo = await repository();
+ const base = await commit(repo, 'base', { 'a.txt': 'a\n', 'b.txt': 'b\n' }, undefined);
+ const ours = await commit(repo, 'ours', { 'a.txt': 'A\n', 'b.txt': 'b\n' }, base);
+ const theirs = await commit(repo, 'theirs', { 'a.txt': 'a\n', 'b.txt': 'B\n' }, base);
+ const both = await commit(repo, 'both', { 'a.txt': 'A\n', 'b.txt': 'B\n' }, undefined);
+
+ const merged = (await git.mergeTree(repo, ours, theirs))._unsafeUnwrap();
+
+ expect(merged.tree.isEqual((await git.treeOfCommit(repo, both))._unsafeUnwrap())).to.be.true;
+ expect(merged.stages).to.deep.equal([]);
+ expect(merged.messages).to.deep.equal([]);
+ });
});
});
diff --git a/test/prompts/sdk/generate.test.ts b/test/prompts/sdk/generate.test.ts
index d89fc56a7..4e7d28f76 100644
--- a/test/prompts/sdk/generate.test.ts
+++ b/test/prompts/sdk/generate.test.ts
@@ -6,6 +6,10 @@ import { ServiceError } from '../../../src/infrastructure/service-error.js';
import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js';
import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
import { GitVersion } from '../../../src/types/git/git-version.js';
+import { CommitId } from '../../../src/types/git/commit-id.js';
+import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js';
+import { Conflict, MergeFiles } from '../../../src/types/sdk/merge-plan.js';
+import { WriteProblem } from '../../../src/types/sdk/write-problem.js';
import { Language } from '../../../src/types/sdk/generate.js';
describe('SdkGeneratePrompts.languageNotAvailable', () => {
@@ -157,24 +161,38 @@ describe('SdkGeneratePrompts, about the repository', () => {
expect(at).to.deep.equal([...at].sort((a, b) => a - b));
});
- it('says a repository was initialised, naming the SDK directory', () => {
+ it('says a repository was initialised, or found, naming the SDK directory', () => {
const info = sinon.stub(log, 'info');
new SdkGeneratePrompts().repositoryInitialised(sdk);
+ new SdkGeneratePrompts().repositoryFound(sdk);
expect(stripVTControlCharacters(info.firstCall.args[0])).to.equal(
"Initialised a git repository in './sdk/python', which keeps your customizations across regenerations."
);
+ expect(stripVTControlCharacters(info.secondCall.args[0])).to.equal(
+ "Found a git repository in './sdk/python', which keeps your customizations across regenerations."
+ );
});
- it('says the SDK was written without a repository when one could not be made, and what the next run does', () => {
- const warn = sinon.stub(log, 'warn');
+ it('says that nothing was written when the repository could not be made', () => {
+ const error = sinon.stub(log, 'error');
new SdkGeneratePrompts().repositoryNotInitialised(sdk, 'git broke');
+ expect(stripVTControlCharacters(error.firstCall.args[0])).to.equal(
+ "A git repository could not be created in './sdk/python', where the SDK's snapshots would be kept: git broke\n" +
+ 'Nothing was written.'
+ );
+ });
+
+ it('says the recorded snapshot is missing, and that this generation starts afresh', () => {
+ const warn = sinon.stub(log, 'warn');
+
+ new SdkGeneratePrompts().snapshotMissing(sdk);
+
expect(stripVTControlCharacters(warn.firstCall.args[0])).to.equal(
- "The SDK was written, but './sdk/python' could not be made a git repository: git broke\n" +
- 'The next run will replace the SDK instead of merging your customizations into it.'
+ "The snapshot './sdk/python' was last generated from is not in its repository, so this generation is recorded afresh."
);
});
@@ -189,3 +207,127 @@ describe('SdkGeneratePrompts, about the repository', () => {
);
});
});
+
+describe('SdkGeneratePrompts, about a merge', () => {
+ const sdk = new DirectoryPath(process.cwd()).join('sdk', 'python');
+ const rel = (value: string) => RelativeFilePath.fromGit(value)!;
+ const files = (overrides: Partial = {}): MergeFiles => ({
+ added: [],
+ updated: [],
+ merged: [],
+ removed: [],
+ kept: [],
+ ...overrides
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('sums up what the generator did and names the files of yours it merged into or left alone, or says nothing changed', () => {
+ const info = sinon.stub(log, 'info');
+ const merged = files({
+ added: [rel('models/author.py')],
+ updated: [rel('README.md'), rel('x.py')],
+ merged: [rel('models/book.py')],
+ kept: [rel('notes.txt')]
+ });
+
+ new SdkGeneratePrompts().sdkMerged(sdk, merged);
+ new SdkGeneratePrompts().sdkUnchanged(sdk);
+
+ const said = stripVTControlCharacters(info.firstCall.args[0]);
+ expect(said).to.match(
+ /^Regenerated the SDK at '\.\/sdk\/python': 1 file added, 2 files updated,? and 1 file merged\.\n/
+ );
+ expect(said).to.contain('Merged with your changes:\n').and.to.contain('book.py');
+ expect(said).to.contain('Kept as yours:\n').and.to.contain('notes.txt');
+ expect(said).to.not.contain('README.md');
+ expect(stripVTControlCharacters(info.secondCall.args[0])).to.equal(
+ "Regenerated the SDK at './sdk/python': nothing changed."
+ );
+ });
+
+ it('lists the conflicts with what each one is, and that nothing was written', () => {
+ const write = sinon.stub(process.stdout, 'write').returns(true);
+ const conflicts: Conflict[] = [
+ { path: rel('models/book.py'), kind: 'bothChanged' },
+ { path: rel('docs/book.md'), kind: 'deletedByYou' }
+ ];
+ try {
+ new SdkGeneratePrompts().conflictsNotAsked(sdk, conflicts);
+ } finally {
+ write.restore();
+ }
+ const note = stripVTControlCharacters(write.args.map(([chunk]) => String(chunk)).join(''));
+
+ expect(note).to.contain('Conflicts in 2 files');
+ expect(note).to.contain('book.py').and.to.contain('changed by you and by the generator');
+ expect(note).to.contain('book.md').and.to.contain('deleted by you, changed by the generator');
+ expect(note).to.contain('Nothing was written.');
+ });
+
+ it('says why a forced run replaced a recorded SDK, and where the committed customizations still are', () => {
+ const warn = sinon.stub(log, 'warn');
+
+ new SdkGeneratePrompts().recordedSdkReplaced(sdk);
+
+ const said = stripVTControlCharacters(warn.firstCall.args[0]);
+ expect(said).to.contain("The new SDK could not be merged into './sdk/python', so --force replaced it");
+ expect(said).to.contain("git -C './sdk/python' diff");
+ });
+
+ it('says that nothing was written when git could not work out the merge', () => {
+ const error = sinon.stub(log, 'error');
+
+ new SdkGeneratePrompts().customCodeFailed('git broke');
+
+ expect(stripVTControlCharacters(error.firstCall.args[0])).to.equal(
+ 'Your customizations could not be merged into the regenerated SDK: git broke\nNothing was written.'
+ );
+ });
+
+ it('names the file a merge could not write, and says when the directory is back as it was', () => {
+ const error = sinon.stub(log, 'error');
+ const info = sinon.stub(log, 'info');
+ const problem: WriteProblem = {
+ file: rel('models/book.py'),
+ operation: 'write',
+ reason: 'EACCES: permission denied'
+ };
+
+ new SdkGeneratePrompts().sdkNotWritten(sdk, problem);
+ new SdkGeneratePrompts().sdkRestored(sdk);
+
+ expect(stripVTControlCharacters(error.firstCall.args[0])).to.match(
+ /^'.*book\.py' could not be written: EACCES: permission denied$/
+ );
+ expect(stripVTControlCharacters(info.firstCall.args[0])).to.equal(
+ "Nothing else was changed; './sdk/python' is as it was."
+ );
+ });
+
+ it('gives the restore command, from the kept snapshot, when the directory could not be put back', () => {
+ const error = sinon.stub(log, 'error');
+ const problem: WriteProblem = { file: rel('models/book.py'), operation: 'remove', reason: 'EBUSY: resource busy' };
+
+ new SdkGeneratePrompts().sdkNotWritten(sdk, problem);
+ new SdkGeneratePrompts().sdkNotRestored(sdk, CommitId.fromGit('a'.repeat(40))!, 'EACCES on README.md');
+
+ expect(stripVTControlCharacters(error.firstCall.args[0])).to.contain('could not be removed: EBUSY: resource busy');
+ const said = stripVTControlCharacters(error.secondCall.args[0]);
+ expect(said).to.contain('The files written before it could not be put back either: EACCES on README.md');
+ expect(said).to.contain(`git -C './sdk/python' restore --source=${'a'.repeat(40)} --worktree -- .`);
+ });
+
+ it('says what the next run does when the snapshot of a merge is not recorded: merge against the previous one', () => {
+ const warn = sinon.stub(log, 'warn');
+
+ new SdkGeneratePrompts().snapshotNotRecordedAfterMerge('git broke');
+
+ expect(stripVTControlCharacters(warn.firstCall.args[0])).to.equal(
+ 'The SDK was merged, but the new snapshot could not be recorded: git broke\n' +
+ 'The next run will merge against the previous one.'
+ );
+ });
+});
diff --git a/test/types/file/relativeFilePath.test.ts b/test/types/file/relativeFilePath.test.ts
new file mode 100644
index 000000000..3a0a82fc4
--- /dev/null
+++ b/test/types/file/relativeFilePath.test.ts
@@ -0,0 +1,33 @@
+import path from 'node:path';
+import { expect } from 'chai';
+import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
+import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js';
+
+describe('RelativeFilePath', () => {
+ it('accepts a path as git names it inside a tree', () => {
+ expect(RelativeFilePath.fromGit('src/models/book.ts')?.toString()).to.equal('src/models/book.ts');
+ expect(RelativeFilePath.fromGit('README.md')?.toString()).to.equal('README.md');
+ });
+
+ for (const value of ['', '/src/a.ts', 'src\\a.ts', 'src//a.ts', './a.ts', '../a.ts', 'src/../a.ts', 'C:/a.ts']) {
+ it(`refuses ${JSON.stringify(value)}`, () => {
+ expect(RelativeFilePath.fromGit(value)).to.be.undefined;
+ });
+ }
+
+ it('is the file on disk below a directory, spelt the way the OS spells it', () => {
+ const sdk = new DirectoryPath(path.join('sdk', 'typescript'));
+
+ const file = RelativeFilePath.fromGit('src/models/book.ts')!.in(sdk);
+
+ expect(file.toString()).to.equal(path.resolve('sdk', 'typescript', 'src', 'models', 'book.ts'));
+ expect(file.name().toString()).to.equal('book.ts');
+ });
+
+ it('compares by value', () => {
+ const a = RelativeFilePath.fromGit('a.ts')!;
+
+ expect(a.isEqual(RelativeFilePath.fromGit('a.ts')!)).to.be.true;
+ expect(a.isEqual(RelativeFilePath.fromGit('b.ts')!)).to.be.false;
+ });
+});
diff --git a/test/types/git-context.test.ts b/test/types/git-context.test.ts
index bce277dc2..3a069f756 100644
--- a/test/types/git-context.test.ts
+++ b/test/types/git-context.test.ts
@@ -1,3 +1,4 @@
+import { Buffer } from 'node:buffer';
import fs from 'fs';
import os from 'os';
import path from 'path';
@@ -5,15 +6,60 @@ import { expect } from 'chai';
import { execa } from 'execa';
import { err } from 'neverthrow';
import sinon from 'sinon';
+import { FileService } from '../../src/infrastructure/file-service.js';
import { GitService } from '../../src/infrastructure/git-service.js';
import { canonical } from '../../src/infrastructure/tmp-extensions.js';
import { DirectoryPath } from '../../src/types/file/directoryPath.js';
+import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js';
import { GitContext } from '../../src/types/git-context.js';
+import { CommitId } from '../../src/types/git/commit-id.js';
import { GitVersion } from '../../src/types/git/git-version.js';
+import { Language, Stability } from '../../src/types/sdk/generate.js';
+import { recordContents } from '../../src/types/sdk/generation-record.js';
+import { MergePlan } from '../../src/types/sdk/merge-plan.js';
import { describeCommit, snapshotFiles } from '../helpers/snapshots.js';
/** Where the SDK directory's own repository keeps each generation as the server returned it. */
const PRISTINE_REF = 'refs/apimatic/pristine';
+const RECORD = path.join('.apimatic', 'generation.json');
+/** The ref a merge keeps the directory under before writing, byte for byte. */
+const PREVIOUS_REF = 'refs/apimatic/previous';
+
+type Files = Record;
+
+const BINARY = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x1a]);
+
+// Long enough that a helper added at the end leaves the file mostly itself, as a real model is.
+const MODEL_FIELDS = [
+ ' /** Unique identifier of the book. */',
+ ' id: string;',
+ ' /** Title of the book. */',
+ ' title: string;',
+ ' /** Full name of the author. */',
+ ' author: string;',
+ ' /** Year of first publication. */',
+ ' year?: number;',
+ ' /** Stock status. */',
+ ' status?: "available" | "sold_out";'
+];
+const MODEL_V1 = ['export type Book = {', ...MODEL_FIELDS, ' /** Price in USD. */', ' price: number;', '};', ''].join(
+ '\n'
+);
+const MODEL_V2 = [
+ 'export type Book = {',
+ ...MODEL_FIELDS,
+ ' /** Retail price in US dollars. */',
+ ' price: number;',
+ ' isbn?: string;',
+ '};',
+ ''
+].join('\n');
+const HELPER = '\nexport function formatPrice(book: Book): string {\n return `$${book.price.toFixed(2)}`;\n}\n';
+const MODEL_V1_WITH_HELPER = MODEL_V1 + HELPER;
+
+const rel = (value: string) => RelativeFilePath.fromGit(value)!;
+const counts = (plan: MergePlan) =>
+ Object.fromEntries(Object.entries(plan.files).map(([kind, paths]) => [kind, paths.length]));
describe('GitContext', () => {
let root: string;
@@ -25,13 +71,13 @@ describe('GitContext', () => {
/** A git that can serve the feature, for the questions that need one. */
const USABLE = GitVersion.parse('git version 2.38.0');
- const write = (file: string, contents: string) => {
+ const write = (file: string, contents: string | Buffer) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, contents);
};
/** A directory of generated files, beside the SDK directory. */
- const generated = (files: Record): DirectoryPath => {
+ const generated = (files: Files): DirectoryPath => {
const directory = fs.mkdtempSync(inRoot('generated-'));
for (const [relative, contents] of Object.entries(files)) {
write(path.join(directory, relative), contents);
@@ -44,6 +90,10 @@ describe('GitContext', () => {
(await git.init(new DirectoryPath(directory)))._unsafeUnwrap();
};
+ /** The generation `files` recorded in the SDK directory's own repository, as a Python stable SDK. */
+ const record = async (files: Files, directory = sdk()) =>
+ (await context(directory).recordSnapshot(generated(files), Language.PYTHON, Stability.STABLE))._unsafeUnwrap();
+
/** The commit the pristine ref names in the repository at `directory`, or none. */
const pristineAt = async (directory: string) =>
(await git.resolveRef(new DirectoryPath(directory), PRISTINE_REF))._unsafeUnwrap();
@@ -78,6 +128,18 @@ describe('GitContext', () => {
}
});
+ describe('hasFilesBesidesGit', () => {
+ it('counts what a save would remove: not a directory that is not there, or one holding only .git', async () => {
+ expect(await context().hasFilesBesidesGit()).to.be.false;
+
+ fs.mkdirSync(inRoot('sdk', 'python', '.git'), { recursive: true });
+ expect(await context().hasFilesBesidesGit()).to.be.false;
+
+ fs.mkdirSync(inRoot('sdk', 'python', '.github'));
+ expect(await context().hasFilesBesidesGit()).to.be.true;
+ });
+ });
+
describe('isRepository', () => {
it('is true for the SDK directory’s own repository, also inside one that encloses it, and records there', async () => {
await ownRepository(root);
@@ -85,7 +147,7 @@ describe('GitContext', () => {
expect((await context().isRepository(USABLE))._unsafeUnwrap()).to.be.true;
- (await context().recordSnapshot(generated({ 'README.md': '# sdk' })))._unsafeUnwrap();
+ await record({ 'README.md': '# sdk' });
expect(await pristineAt(sdk())).to.not.be.undefined;
expect(await pristineAt(root)).to.be.undefined;
});
@@ -118,7 +180,7 @@ describe('GitContext', () => {
expect(topLevel.called).to.be.false;
});
- for (const [what, git] of [
+ for (const [what, version] of [
['no git', GitVersion.none()],
['a git too old', GitVersion.parse('git version 2.30.2')]
] as const) {
@@ -126,72 +188,488 @@ describe('GitContext', () => {
await ownRepository();
const topLevel = sinon.stub(GitService.prototype, 'topLevel');
- expect((await context().isRepository(git))._unsafeUnwrap()).to.be.false;
+ expect((await context().isRepository(version))._unsafeUnwrap()).to.be.false;
expect(topLevel.called).to.be.false;
});
}
});
+ describe('generationRecord', () => {
+ it('is none without a file, or with one a CLI did not write', async () => {
+ expect((await context().generationRecord()).isFound()).to.be.false;
+
+ write(inRoot('sdk', 'python', RECORD), 'not json');
+ expect((await context().generationRecord()).isFound()).to.be.false;
+ });
+
+ it('is what the last recording wrote: the generation and its snapshot', async () => {
+ await ownRepository();
+ const commit = await record({ 'README.md': '# sdk' });
+
+ const found = await context().generationRecord();
+
+ expect(found.baseFor(Language.PYTHON, Stability.STABLE)).to.deep.equal(commit);
+ expect(found.baseFor(Language.PYTHON, Stability.BETA)).to.be.undefined;
+ expect(fs.readFileSync(inRoot('sdk', 'python', RECORD), 'utf-8')).to.equal(
+ recordContents(Language.PYTHON, Stability.STABLE, commit)
+ );
+ });
+ });
+
+ describe('hasSnapshot', () => {
+ it('is whether the repository holds the commit, and false for none without running git', async () => {
+ await ownRepository();
+ const commit = await record({ 'README.md': '# sdk' });
+
+ expect((await context().hasSnapshot(commit))._unsafeUnwrap()).to.be.true;
+ expect((await context().hasSnapshot(CommitId.fromGit('f'.repeat(40))))._unsafeUnwrap()).to.be.false;
+
+ const hasCommit = sinon.stub(GitService.prototype, 'hasCommit');
+ expect((await context().hasSnapshot(undefined))._unsafeUnwrap()).to.be.false;
+ expect(hasCommit.called).to.be.false;
+ });
+
+ it('reports a git that does not answer', async () => {
+ await ownRepository();
+ sinon.stub(GitService.prototype, 'hasCommit').resolves(err('git broke'));
+
+ expect((await context().hasSnapshot(CommitId.fromGit('f'.repeat(40))))._unsafeUnwrapErr()).to.equal('git broke');
+ });
+ });
+
+ describe('save', () => {
+ it('replaces what the SDK directory holds with the generation, except its .git', async () => {
+ write(inRoot('sdk', 'python', '.git', 'HEAD'), 'ref: refs/heads/main');
+ write(inRoot('sdk', 'python', 'old.py'), 'old');
+ write(inRoot('sdk', 'python', RECORD), '{}');
+
+ const saved = await context().save(generated({ 'README.md': '# sdk' }));
+
+ expect(saved.toString()).to.equal(sdk());
+ expect(fs.readdirSync(sdk()).sort()).to.deep.equal(['.git', 'README.md']);
+ expect(fs.readFileSync(inRoot('sdk', 'python', '.git', 'HEAD'), 'utf-8')).to.equal('ref: refs/heads/main');
+ });
+ });
+
describe('recordSnapshot', () => {
- it('records the first snapshot with no parent, under the pristine ref, with every file', async () => {
+ it('records the first snapshot with no parent, under the pristine ref, with every file, named for the generation', async () => {
await ownRepository();
- const commit = (
- await context().recordSnapshot(
- generated({ 'README.md': '# v1', 'src/index.py': 'x', '.gitignore': 'dist/\n', 'dist/x.py': 'x' })
- )
- )._unsafeUnwrap();
+ const commit = await record({
+ 'README.md': '# v1',
+ 'src/index.py': 'x',
+ '.gitignore': 'dist/\n',
+ 'dist/x.py': 'x'
+ });
expect(await pristineAt(sdk())).to.deep.equal(commit);
expect(await describeCommit(new DirectoryPath(sdk()), commit)).to.deep.equal({
parents: [],
authorName: 'APIMatic CLI',
- subject: 'apimatic: pristine'
+ subject: 'apimatic: pristine python stable'
});
const files = await snapshotFiles(new DirectoryPath(sdk()), commit);
expect([...files.keys()]).to.have.members(['README.md', 'src/index.py', '.gitignore', 'dist/x.py']);
expect(files.get('src/index.py')).to.equal('x');
+ expect((await context().generationRecord()).baseFor(Language.PYTHON, Stability.STABLE)).to.deep.equal(commit);
});
it('records an SDK whose path holds a space and a folder starting with a dot, and finds it again', async () => {
const directory = inRoot('My API', 'sdk', '.python');
await ownRepository(directory);
- const commit = (await context(directory).recordSnapshot(generated({ 'README.md': '# v1' })))._unsafeUnwrap();
+ const commit = await record({ 'README.md': '# v1' }, directory);
expect(await pristineAt(directory)).to.deep.equal(commit);
expect((await context(directory).isRepository(USABLE))._unsafeUnwrap()).to.be.true;
});
- it('chains the next snapshot onto the last and moves the ref', async () => {
+ it('chains the next snapshot onto the last and moves the ref, the record following', async () => {
await ownRepository();
- const first = (await context().recordSnapshot(generated({ 'README.md': '# v1' })))._unsafeUnwrap();
+ const first = await record({ 'README.md': '# v1' });
- const second = (await context().recordSnapshot(generated({ 'README.md': '# v2' })))._unsafeUnwrap();
+ const second = await record({ 'README.md': '# v2' });
expect((await describeCommit(new DirectoryPath(sdk()), second)).parents).to.deep.equal([first]);
expect(await pristineAt(sdk())).to.deep.equal(second);
expect((await snapshotFiles(new DirectoryPath(sdk()), first)).get('README.md')).to.equal('# v1');
+ expect((await context().generationRecord()).baseFor(Language.PYTHON, Stability.STABLE)).to.deep.equal(second);
});
- it('keeps the last snapshot when the generation is identical', async () => {
+ it('keeps the last snapshot when the generation is identical, and still writes the record', async () => {
await ownRepository();
- const first = (await context().recordSnapshot(generated({ 'README.md': '# v1' })))._unsafeUnwrap();
+ const first = await record({ 'README.md': '# v1' });
+ fs.rmSync(inRoot('sdk', 'python', '.apimatic'), { recursive: true });
- const again = (await context().recordSnapshot(generated({ 'README.md': '# v1' })))._unsafeUnwrap();
- const changed = (await context().recordSnapshot(generated({ 'README.md': '# v2' })))._unsafeUnwrap();
+ const again = await record({ 'README.md': '# v1' });
+ const changed = await record({ 'README.md': '# v2' });
expect(again).to.deep.equal(first);
expect((await describeCommit(new DirectoryPath(sdk()), changed)).parents).to.deep.equal([first]);
+ expect(fs.existsSync(inRoot('sdk', 'python', RECORD))).to.be.true;
});
it('leaves the repository’s own index alone', async () => {
await ownRepository();
- (await context().recordSnapshot(generated({ 'README.md': '# v1' })))._unsafeUnwrap();
+ await record({ 'README.md': '# v1' });
expect((await execa('git', ['ls-files'], { cwd: sdk() })).stdout).to.equal('');
});
+
+ it('fails, the snapshot committed all the same, when the record cannot be written', async () => {
+ await ownRepository();
+ sinon.stub(FileService.prototype, 'replaceContents').rejects(new Error('EACCES: permission denied'));
+
+ const recorded = await context().recordSnapshot(
+ generated({ 'README.md': '# v1' }),
+ Language.PYTHON,
+ Stability.STABLE
+ );
+
+ expect(recorded._unsafeUnwrapErr()).to.contain('EACCES');
+ expect(await pristineAt(sdk())).to.not.be.undefined;
+ expect(fs.existsSync(inRoot('sdk', 'python', RECORD))).to.be.false;
+ });
+ });
+
+ describe('merge and write', () => {
+ let counter = 0;
+
+ /**
+ * `base` was generated last time, `disk` is the SDK directory now, `fresh` is the generator's new
+ * output: the plan, and the directory once the plan is written, unless the test reads the plan alone.
+ * `beforeWrite` runs once the plan is made, for a test that breaks the write, which is then not unwrapped.
+ */
+ const merged = async (
+ base: Files,
+ disk: Files,
+ fresh: Files,
+ { written = true, beforeWrite }: { written?: boolean; beforeWrite?: () => void } = {}
+ ) => {
+ const directory = inRoot(`sdk-${counter++}`);
+ await ownRepository(directory);
+ const baseCommit = await record(base, directory);
+ for (const [relative, contents] of Object.entries(disk)) {
+ write(path.join(directory, relative), contents);
+ }
+ const freshDirectory = generated(fresh);
+ const plan = (await context(directory).merge(baseCommit, freshDirectory))._unsafeUnwrap();
+ beforeWrite?.();
+ const previous = (await context(directory).keepPrevious(baseCommit))._unsafeUnwrap();
+ const landed =
+ written || beforeWrite !== undefined ? await context(directory).write(plan, freshDirectory) : undefined;
+ if (beforeWrite === undefined) {
+ landed?._unsafeUnwrap();
+ }
+ const at = (relative: string) => path.join(directory, relative);
+ const list = (dir: string): string[] =>
+ fs
+ .readdirSync(dir, { withFileTypes: true })
+ .flatMap((entry) =>
+ entry.isDirectory()
+ ? list(path.join(dir, entry.name))
+ : [path.relative(directory, path.join(dir, entry.name)).split(path.sep).join('/')]
+ );
+ return {
+ plan,
+ previous,
+ landed,
+ directory,
+ read: (relative: string) => (fs.existsSync(at(relative)) ? fs.readFileSync(at(relative), 'utf-8') : undefined),
+ bytes: (relative: string) => fs.readFileSync(at(relative)),
+ exists: (relative: string) => fs.existsSync(at(relative)),
+ paths: () =>
+ list(directory)
+ .filter((relative) => !relative.startsWith('.git/') && !relative.startsWith('.apimatic/'))
+ .sort()
+ };
+ };
+
+ /** Makes every write of a file named `name` fail, and of `a.ts` too when it is being put back to `1`. */
+ const breaking =
+ (name: string, andRestoringA = false) =>
+ () => {
+ const real = FileService.prototype.writeBytes;
+ sinon.stub(FileService.prototype, 'writeBytes').callsFake(function (this: FileService, file, content) {
+ const target = file.toString();
+ const restoringA = andRestoringA && target.endsWith('a.ts') && Buffer.from(content).toString() === '1\r\n';
+ return target.endsWith(name) || restoringA
+ ? Promise.reject(new Error(`EACCES: permission denied, open '${target}'`))
+ : real.call(this, file, content);
+ });
+ };
+
+ it('merges your edit with the generator’s change when they touch different lines', async () => {
+ const { plan, read } = await merged(
+ { 'book.ts': MODEL_V1 },
+ { 'book.ts': MODEL_V1_WITH_HELPER },
+ { 'book.ts': MODEL_V2 }
+ );
+
+ expect(read('book.ts')).to.equal(MODEL_V2 + HELPER);
+ expect(plan.files.merged.map(String)).to.deep.equal(['book.ts']);
+ expect(plan.hasConflicts()).to.be.false;
+ });
+
+ it('reports the lines you and the generator both changed as a conflict', async () => {
+ const yours = MODEL_V1.replace('Price in USD.', 'Price in USD, VAT included.');
+
+ const { plan, read } = await merged(
+ { 'book.ts': MODEL_V1 },
+ { 'book.ts': yours },
+ { 'book.ts': MODEL_V2 },
+ {
+ written: false
+ }
+ );
+
+ expect(plan.conflicts).to.deep.equal([{ path: rel('book.ts'), kind: 'bothChanged' }]);
+ expect(read('book.ts')).to.equal(yours);
+ });
+
+ it('adds a file the generator produced for the first time, and never touches one that is yours alone, nor its own record', async () => {
+ const { plan, read } = await merged(
+ { 'a.ts': 'a\n' },
+ { 'a.ts': 'a\n', 'custom/format.ts': 'mine\n' },
+ { 'a.ts': 'a\n', 'delete-book.ts': 'new\n' }
+ );
+
+ expect(read('delete-book.ts')).to.equal('new\n');
+ expect(read('custom/format.ts')).to.equal('mine\n');
+ expect(plan.writes.map((write) => write.path.toString())).to.deep.equal(['delete-book.ts']);
+ expect(plan.files.added.map(String)).to.deep.equal(['delete-book.ts']);
+ expect(plan.files.kept.map(String)).to.deep.equal(['custom/format.ts']);
+ });
+
+ it('reports a path you and the generator both added with different content as a conflict, and accepts one with the same', async () => {
+ const differ = await merged(
+ { 'a.ts': 'a\n' },
+ { 'a.ts': 'a\n', 'retry.ts': 'yours\n' },
+ { 'a.ts': 'a\n', 'retry.ts': 'generated\n' },
+ { written: false }
+ );
+ const same = await merged(
+ { 'a.ts': 'a\n' },
+ { 'a.ts': 'a\n', 'retry.ts': 'same\n' },
+ { 'a.ts': 'a\n', 'retry.ts': 'same\n' }
+ );
+
+ expect(differ.plan.conflicts).to.deep.equal([{ path: rel('retry.ts'), kind: 'bothAdded' }]);
+ expect(same.plan.writes).to.be.empty;
+ expect(same.plan.hasConflicts()).to.be.false;
+ });
+
+ it('follows a file you moved and merges the generator’s change into it where it is now', async () => {
+ const { plan, read, exists } = await merged(
+ { 'models/book.ts': MODEL_V1, 'b.ts': 'b\n' },
+ { 'models/catalog/book.ts': MODEL_V1_WITH_HELPER, 'b.ts': 'b\n' },
+ { 'models/book.ts': MODEL_V2, 'b.ts': 'b\n' }
+ );
+
+ expect(read('models/catalog/book.ts')).to.equal(MODEL_V2 + HELPER);
+ expect(exists('models/book.ts')).to.be.false;
+ expect(plan.hasConflicts()).to.be.false;
+ });
+
+ it('carries your edit to a file the generator renamed, and removes the old name', async () => {
+ const { plan, read, exists } = await merged(
+ { 'models/book.ts': MODEL_V1, 'b.ts': 'b\n' },
+ { 'models/book.ts': MODEL_V1_WITH_HELPER, 'b.ts': 'b\n' },
+ { 'models/book-model.ts': MODEL_V2, 'b.ts': 'b\n' }
+ );
+
+ expect(read('models/book-model.ts')).to.equal(MODEL_V2 + HELPER);
+ expect(exists('models/book.ts')).to.be.false;
+ expect(plan.hasConflicts()).to.be.false;
+ });
+
+ it('renames a file whose name changed only in case, on a filesystem that ignores case too', async () => {
+ const { paths, read } = await merged(
+ { 'models/user.ts': 'user\n', 'b.ts': 'b\n' },
+ { 'models/user.ts': 'user\n', 'b.ts': 'b\n' },
+ { 'models/User.ts': 'user\n', 'b.ts': 'b\n' }
+ );
+
+ expect(paths()).to.deep.equal(['b.ts', 'models/User.ts']);
+ expect(read('models/User.ts')).to.equal('user\n');
+ });
+
+ describe('two spec versions in a row, as a generator ships them', () => {
+ const v1: Files = {
+ 'src/models/book.ts': MODEL_V1,
+ 'src/sdk/authors.ts': 'export class Authors {}\n',
+ 'docs/models/book.md': '# Book\n\nprice\n'
+ };
+ const v2: Files = {
+ 'src/models/book.ts': MODEL_V2,
+ 'src/sdk/authors.ts': 'export class Authors {}\n',
+ 'src/funcs/delete-book.ts': 'export function deleteBook() {}\n',
+ 'docs/models/book.md': '# Book\n\nprice\nisbn\n'
+ };
+
+ it('counts a helper, a new file and a custom file through the v2 spec, and reports a deleted docs page as a conflict', async () => {
+ const { plan } = await merged(
+ v1,
+ {
+ 'src/models/book.ts': MODEL_V1_WITH_HELPER,
+ 'src/sdk/authors.ts': 'export class Authors {}\n',
+ 'src/custom/format.ts': 'mine\n'
+ },
+ v2,
+ { written: false }
+ );
+
+ expect(plan.conflicts).to.deep.equal([{ path: rel('docs/models/book.md'), kind: 'deletedByYou' }]);
+ expect(counts(plan)).to.deep.equal({ added: 1, updated: 0, merged: 1, removed: 0, kept: 1 });
+ });
+
+ it('reports an edited file the v3 spec drops as a conflict, and removes nothing else it did not produce', async () => {
+ const v3: Files = { 'src/models/book.ts': MODEL_V2, 'docs/models/book.md': '# Book\n\nprice\nisbn\n' };
+
+ const { plan } = await merged(
+ v1,
+ {
+ 'src/models/book.ts': MODEL_V1,
+ 'src/sdk/authors.ts': 'export class Authors { getMany() {} }\n',
+ 'docs/models/book.md': '# Book\n\nprice\n'
+ },
+ v3,
+ { written: false }
+ );
+
+ expect(plan.conflicts).to.deep.equal([{ path: rel('src/sdk/authors.ts'), kind: 'noLongerGenerated' }]);
+ expect(plan.files.updated).to.have.length(2);
+ expect(plan.deletes).to.deep.equal([]);
+ });
+ });
+
+ describe('a write that fails partway', () => {
+ const BEFORE: Files = { 'a.ts': '1\r\n', 'b.ts': '1\n', 'c.ts': '1\n' };
+ const AFTER: Files = { 'a.ts': '2\n', 'b.ts': '2\n', 'd.ts': 'new\n' };
+
+ it('names the file and stops there, and a restore puts back what it had changed, byte for byte', async () => {
+ const { plan, previous, landed, directory, read, paths } = await merged(BEFORE, BEFORE, AFTER, {
+ beforeWrite: breaking('b.ts')
+ });
+
+ const problem = landed!._unsafeUnwrapErr();
+ expect(problem.file.toString()).to.equal('b.ts');
+ expect(problem.operation).to.equal('write');
+ expect(problem.reason).to.contain('EACCES');
+ expect(read('a.ts')).to.equal('2\r\n');
+ expect(read('c.ts')).to.be.undefined;
+
+ (await context(directory).restore(previous, plan))._unsafeUnwrap();
+
+ expect(paths()).to.deep.equal(['a.ts', 'b.ts', 'c.ts']);
+ expect([read('a.ts'), read('b.ts'), read('c.ts')]).to.deep.equal(['1\r\n', '1\n', '1\n']);
+ });
+
+ it('reports what a restore could not put back, the rest put back all the same', async () => {
+ const { plan, previous, directory, read } = await merged(BEFORE, BEFORE, AFTER, {
+ beforeWrite: breaking('b.ts', true)
+ });
+
+ const restored = await context(directory).restore(previous, plan);
+
+ expect(restored._unsafeUnwrapErr()).to.contain('a.ts');
+ expect(read('a.ts')).to.equal('2\r\n');
+ expect(read('c.ts')).to.equal('1\n');
+ });
+
+ it('keeps the directory as it was under the previous ref, byte for byte, before writing', async () => {
+ const { directory } = await merged(BEFORE, BEFORE, AFTER);
+
+ const previous = (await git.resolveRef(new DirectoryPath(directory), PREVIOUS_REF))._unsafeUnwrap()!;
+
+ const kept = await snapshotFiles(new DirectoryPath(directory), previous);
+ expect(kept.get('a.ts')).to.equal('1\r\n');
+ expect([...kept.keys()]).to.have.members(['a.ts', 'b.ts', 'c.ts', '.apimatic/generation.json']);
+ });
+ });
+
+ describe('how the plan lands on disk', () => {
+ it('renames a directory whose name changed only in case', async () => {
+ const { paths } = await merged(
+ { 'Models/a.ts': 'a\n', 'Models/b.ts': 'b\n', 'c.ts': 'c\n' },
+ { 'Models/a.ts': 'a\n', 'Models/b.ts': 'b\n', 'c.ts': 'c\n' },
+ { 'models/a.ts': 'a\n', 'models/b.ts': 'b\n', 'c.ts': 'c\n' }
+ );
+
+ expect(paths()).to.deep.equal(['c.ts', 'models/a.ts', 'models/b.ts']);
+ });
+
+ it('lets a file become a directory and a directory become a file', async () => {
+ const { paths, read } = await merged(
+ { docs: 'docs\n', 'api/index.md': 'api\n' },
+ { docs: 'docs\n', 'api/index.md': 'api\n' },
+ { 'docs/index.md': 'docs\n', api: 'api\n' }
+ );
+
+ expect(paths()).to.deep.equal(['api', 'docs/index.md']);
+ expect(read('docs/index.md')).to.equal('docs\n');
+ expect(read('api')).to.equal('api\n');
+ });
+
+ it('removes the directories a removed file leaves empty, and no other', async () => {
+ const { exists } = await merged(
+ { 'models/old/deep/a.ts': 'a\n', 'models/keep.ts': 'k\n' },
+ { 'models/old/deep/a.ts': 'a\n', 'models/keep.ts': 'k\n' },
+ { 'models/keep.ts': 'k\n' }
+ );
+
+ expect(exists('models/old')).to.be.false;
+ expect(exists('models/keep.ts')).to.be.true;
+ });
+ });
+
+ describe('content rules', () => {
+ it('reads a line ending alone as no change, writes a merged file with the ending it has on disk, and leaves an untouched CRLF file as it is', async () => {
+ const { plan, read } = await merged(
+ { 'a.ts': 'one\ntwo\n', 'b.ts': 'b\n' },
+ { 'a.ts': 'one\r\ntwo\r\nthree\r\n', 'b.ts': 'b\r\n' },
+ { 'a.ts': 'zero\none\ntwo\n', 'b.ts': 'b\n' }
+ );
+
+ expect(read('a.ts')).to.equal('zero\r\none\r\ntwo\r\nthree\r\n');
+ expect(read('b.ts')).to.equal('b\r\n');
+ expect(plan.files.merged).to.have.length(1);
+ expect(plan.hasConflicts()).to.be.false;
+ });
+
+ it('keeps the generator’s CRLF in a file it changes, gives a new file the generator’s ending, and keeps LF where you chose it', async () => {
+ const { read } = await merged(
+ { 'a.cs': 'one\r\ntwo\r\n', 'b.cs': 'one\r\ntwo\r\n', 'c.cs': 'c\r\n' },
+ { 'a.cs': 'one\r\ntwo\r\n', 'b.cs': 'mine\none\ntwo\n', 'c.cs': 'c\r\n' },
+ { 'a.cs': 'one\r\ntwo\r\nthree\r\n', 'b.cs': 'one\r\ntwo\r\nthree\r\n', 'c.cs': 'c\r\n', 'd.cs': 'new\r\n' }
+ );
+
+ expect(read('a.cs')).to.equal('one\r\ntwo\r\nthree\r\n');
+ expect(read('b.cs')).to.equal('mine\none\ntwo\nthree\n');
+ expect(read('d.cs')).to.equal('new\r\n');
+ });
+
+ it('replaces an unedited binary and reports an edited one the generator changed too as a conflict', async () => {
+ const changed = Buffer.concat([BINARY, Buffer.from([1])]);
+ const edited = Buffer.concat([BINARY, Buffer.from([2])]);
+
+ const replaced = await merged({ 'logo.png': BINARY }, { 'logo.png': BINARY }, { 'logo.png': changed });
+ const both = await merged(
+ { 'logo.png': BINARY },
+ { 'logo.png': edited },
+ { 'logo.png': changed },
+ {
+ written: false
+ }
+ );
+
+ expect(replaced.bytes('logo.png')).to.deep.equal(changed);
+ expect(replaced.plan.files.updated).to.have.length(1);
+ expect(both.bytes('logo.png')).to.deep.equal(edited);
+ expect(both.plan.conflicts).to.deep.equal([{ path: rel('logo.png'), kind: 'binary' }]);
+ });
+ });
});
});
diff --git a/test/types/sdk-context.test.ts b/test/types/sdk-context.test.ts
deleted file mode 100644
index f9eb119b3..000000000
--- a/test/types/sdk-context.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import fs from 'fs';
-import os from 'os';
-import path from 'path';
-import { expect } from 'chai';
-import { DirectoryPath } from '../../src/types/file/directoryPath.js';
-import { SdkContext } from '../../src/types/sdk-context.js';
-
-describe('SdkContext', () => {
- let root: string;
- let context: SdkContext;
-
- const inRoot = (...segments: string[]) => path.join(root, ...segments);
-
- beforeEach(() => {
- root = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-context-'));
- context = new SdkContext(new DirectoryPath(inRoot('sdk', 'python')));
- });
-
- afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
-
- describe('exists', () => {
- it('is whether the directory holds anything, so a missing or empty one is written without a question', async () => {
- expect(await context.exists()).to.be.false;
-
- fs.mkdirSync(inRoot('sdk', 'python'), { recursive: true });
- expect(await context.exists()).to.be.false;
-
- fs.writeFileSync(inRoot('sdk', 'python', 'README.md'), '# mine');
- expect(await context.exists()).to.be.true;
- });
- });
-
- describe('save', () => {
- it('replaces what the SDK directory holds with the generation, and answers with the directory', async () => {
- fs.mkdirSync(inRoot('generated'));
- fs.writeFileSync(inRoot('generated', 'README.md'), '# sdk');
- fs.mkdirSync(inRoot('sdk', 'python'), { recursive: true });
- fs.writeFileSync(inRoot('sdk', 'python', 'old.py'), 'old');
-
- const saved = await context.save(new DirectoryPath(inRoot('generated')));
-
- expect(saved.toString()).to.equal(inRoot('sdk', 'python'));
- expect(fs.readdirSync(inRoot('sdk', 'python'))).to.deep.equal(['README.md']);
- });
- });
-});
diff --git a/test/types/sdk/file-content.test.ts b/test/types/sdk/file-content.test.ts
new file mode 100644
index 000000000..899858217
--- /dev/null
+++ b/test/types/sdk/file-content.test.ts
@@ -0,0 +1,27 @@
+import { Buffer } from 'node:buffer';
+import { expect } from 'chai';
+import { isBinary, keepsLineEndings, lineEndingFor } from '../../../src/types/sdk/file-content.js';
+
+const bytes = (text: string) => new Uint8Array(Buffer.from(text, 'latin1'));
+const text = (content: Uint8Array) => Buffer.from(content).toString('latin1');
+
+describe('file content rules', () => {
+ it('takes a NUL in the first 8000 bytes as binary, as git does', () => {
+ expect(isBinary(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0x1a]))).to.be.true;
+ expect(isBinary(bytes('plain text\n'))).to.be.false;
+ expect(isBinary(new Uint8Array([...bytes('x'.repeat(8000)), 0x00]))).to.be.false;
+ });
+
+ it('leaves the line endings of a binary file, or one that already carries a CR, alone', () => {
+ expect(keepsLineEndings(bytes('one\r\ntwo\n'))).to.be.true;
+ expect(keepsLineEndings(new Uint8Array([0x00, 0x0a]))).to.be.true;
+ expect(keepsLineEndings(bytes('one\ntwo\n'))).to.be.false;
+ });
+
+ it('writes a file the way its sample ends its lines, and as it is without one', () => {
+ expect(text(lineEndingFor(bytes('one\ntwo\n'), bytes('a\r\nb\r\n')))).to.equal('one\r\ntwo\r\n');
+ expect(text(lineEndingFor(bytes('one\ntwo\n'), bytes('a\nb\n')))).to.equal('one\ntwo\n');
+ expect(text(lineEndingFor(bytes('one\ntwo\n'), undefined))).to.equal('one\ntwo\n');
+ expect(text(lineEndingFor(bytes('one\r\ntwo\n'), bytes('a\r\n')))).to.equal('one\r\ntwo\n');
+ });
+});
diff --git a/test/types/sdk/generation-record.test.ts b/test/types/sdk/generation-record.test.ts
new file mode 100644
index 000000000..9a6e87a76
--- /dev/null
+++ b/test/types/sdk/generation-record.test.ts
@@ -0,0 +1,57 @@
+import { expect } from 'chai';
+import { CommitId } from '../../../src/types/git/commit-id.js';
+import { Language, Stability } from '../../../src/types/sdk/generate.js';
+import { GenerationRecord, recordContents } from '../../../src/types/sdk/generation-record.js';
+
+const SHA = 'a'.repeat(40);
+
+const json = (overrides: Record = {}) =>
+ JSON.stringify({ schemaVersion: 1, language: 'typescript', stability: 'stable', pristine: SHA, ...overrides });
+
+describe('GenerationRecord', () => {
+ it('reads what a recording wrote, and is a base only for the generation it names', () => {
+ const record = GenerationRecord.parse(recordContents(Language.PYTHON, Stability.BETA, CommitId.fromGit(SHA)!));
+
+ expect(record.isFound()).to.be.true;
+ expect(record.baseFor(Language.PYTHON, Stability.BETA)?.toString()).to.equal(SHA);
+ expect(record.baseFor(Language.PYTHON, Stability.STABLE)).to.be.undefined;
+ expect(record.baseFor(Language.TYPESCRIPT, Stability.BETA)).to.be.undefined;
+ });
+
+ it('is written with its fields in a fixed order, so a committed record diffs only when something changed', () => {
+ expect(recordContents(Language.TYPESCRIPT, Stability.STABLE, CommitId.fromGit(SHA)!)).to.equal(
+ [
+ '{',
+ ' "schemaVersion": 1,',
+ ' "language": "typescript",',
+ ' "stability": "stable",',
+ ` "pristine": "${SHA}"`,
+ '}',
+ ''
+ ].join('\n')
+ );
+ });
+
+ it('is none for what it cannot read, as for no record at all', () => {
+ const unreadable = [
+ 'not json',
+ '[]',
+ json({ schemaVersion: 2 }),
+ json({ language: 'cobol' }),
+ json({ stability: 'alpha' }),
+ json({ pristine: 'HEAD' })
+ ];
+ for (const text of unreadable) {
+ expect(GenerationRecord.parse(text).isFound(), text).to.be.false;
+ expect(GenerationRecord.parse(text).baseFor(Language.TYPESCRIPT, Stability.STABLE), text).to.be.undefined;
+ }
+ expect(GenerationRecord.none().isFound()).to.be.false;
+ expect(GenerationRecord.none().baseFor(Language.TYPESCRIPT, Stability.STABLE)).to.be.undefined;
+ });
+
+ it('reads a record another tool annotated', () => {
+ const record = GenerationRecord.parse(json({ generatedAt: 'yesterday', cliVersion: 2 }));
+
+ expect(record.baseFor(Language.TYPESCRIPT, Stability.STABLE)?.toString()).to.equal(SHA);
+ });
+});
diff --git a/test/types/sdk/merge-plan.test.ts b/test/types/sdk/merge-plan.test.ts
new file mode 100644
index 000000000..ae3b319e9
--- /dev/null
+++ b/test/types/sdk/merge-plan.test.ts
@@ -0,0 +1,134 @@
+import { Buffer } from 'node:buffer';
+import { expect } from 'chai';
+import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js';
+import { BlobId } from '../../../src/types/git/blob-id.js';
+import { MergeTreeOutput } from '../../../src/types/git/merge-tree.js';
+import { TreeId } from '../../../src/types/git/tree-id.js';
+import { MergeInputs, MergePlan } from '../../../src/types/sdk/merge-plan.js';
+
+const TREE = TreeId.fromGit('c'.repeat(40))!;
+const blob = (name: string) => BlobId.fromGit(Buffer.from(name).toString('hex').padEnd(40, '0'))!;
+const rel = (value: string) => RelativeFilePath.fromGit(value)!;
+const bytes = (text: string) => new Uint8Array(Buffer.from(text));
+
+const merge = (overrides: Partial = {}): MergeTreeOutput => ({
+ tree: TREE,
+ stages: [],
+ messages: [],
+ ...overrides
+});
+
+const inputs = (overrides: Partial): MergeInputs => ({
+ merge: merge(),
+ yours: [],
+ generated: [],
+ writes: [],
+ deletes: [],
+ basePaths: [],
+ ...overrides
+});
+
+const counts = (plan: MergePlan) =>
+ Object.fromEntries(Object.entries(plan.files).map(([kind, paths]) => [kind, paths.length]));
+
+describe('MergePlan.from', () => {
+ it('passes the writes and deletions through, counted by who changed what', () => {
+ const plan = MergePlan.from(
+ inputs({
+ basePaths: ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts'].map(rel),
+ yours: [
+ { status: 'M', path: rel('b.ts'), blob: blob('by') },
+ { status: 'M', path: rel('c.ts'), blob: blob('cy') }
+ ],
+ generated: [
+ { status: 'M', path: rel('a.ts'), blob: blob('ag') },
+ { status: 'M', path: rel('b.ts'), blob: blob('bg') },
+ { status: 'D', path: rel('d.ts') },
+ { status: 'A', path: rel('new.ts'), blob: blob('ng') }
+ ],
+ writes: [
+ { path: rel('a.ts'), content: bytes('ag') },
+ { path: rel('b.ts'), content: bytes('bm') },
+ { path: rel('new.ts'), content: bytes('ng') }
+ ],
+ deletes: ['d.ts'].map(rel)
+ })
+ );
+
+ expect(plan.writes).to.deep.equal([
+ { path: rel('a.ts'), content: bytes('ag') },
+ { path: rel('b.ts'), content: bytes('bm') },
+ { path: rel('new.ts'), content: bytes('ng') }
+ ]);
+ expect(plan.deletes).to.deep.equal(['d.ts'].map(rel));
+ expect(counts(plan)).to.deep.equal({ added: 1, updated: 1, merged: 1, removed: 1, kept: 1 });
+ expect(plan.hasConflicts()).to.be.false;
+ expect(plan.isEmpty()).to.be.false;
+ });
+
+ it('names each conflict by its kind, from the message and from which stages are present', () => {
+ const plan = MergePlan.from(
+ inputs({
+ merge: merge({
+ stages: [
+ { mode: '100644', oid: blob('1'), stage: 1, path: rel('deleted-by-you.ts') },
+ { mode: '100644', oid: blob('3'), stage: 3, path: rel('deleted-by-you.ts') },
+ { mode: '100644', oid: blob('1'), stage: 1, path: rel('dropped.ts') },
+ { mode: '100644', oid: blob('2'), stage: 2, path: rel('dropped.ts') }
+ ],
+ messages: [
+ { paths: ['same.ts'].map(rel), kind: 'informational', message: 'Auto-merging same.ts' },
+ { paths: ['same.ts'].map(rel), kind: 'contents', message: 'CONFLICT (content): Merge conflict in same.ts' },
+ { paths: ['added.ts'].map(rel), kind: 'addAdd', message: 'CONFLICT (add/add): Merge conflict in added.ts' },
+ { paths: ['logo.png'].map(rel), kind: 'binary', message: 'warning: Cannot merge binary files: logo.png' },
+ {
+ paths: ['logo.png'].map(rel),
+ kind: 'contents',
+ message: 'CONFLICT (content): Merge conflict in logo.png'
+ },
+ { paths: ['deleted-by-you.ts'].map(rel), kind: 'modifyDelete', message: 'CONFLICT (modify/delete): …' },
+ { paths: ['dropped.ts'].map(rel), kind: 'modifyDelete', message: 'CONFLICT (modify/delete): …' },
+ {
+ paths: ['x.ts', 'y.ts'].map(rel),
+ kind: 'otherConflict',
+ message: 'CONFLICT (rename/rename): a.ts renamed to x.ts and y.ts'
+ }
+ ]
+ }),
+ writes: [
+ { path: rel('same.ts'), content: bytes('m') },
+ { path: rel('deleted-by-you.ts'), content: bytes('3') }
+ ]
+ })
+ );
+
+ expect(plan.conflicts).to.deep.equal([
+ { path: rel('same.ts'), kind: 'bothChanged' },
+ { path: rel('added.ts'), kind: 'bothAdded' },
+ { path: rel('logo.png'), kind: 'binary' },
+ { path: rel('deleted-by-you.ts'), kind: 'deletedByYou' },
+ { path: rel('dropped.ts'), kind: 'noLongerGenerated' },
+ { path: rel('x.ts'), kind: 'other' },
+ { path: rel('y.ts'), kind: 'other' }
+ ]);
+ expect(plan.hasConflicts()).to.be.true;
+ expect(counts(plan)).to.deep.equal({ added: 0, updated: 0, merged: 0, removed: 0, kept: 0 });
+ });
+
+ it('counts a file you added, which the generator never produced, among the kept ones', () => {
+ const plan = MergePlan.from(
+ inputs({
+ basePaths: ['a.ts'].map(rel),
+ yours: [
+ { status: 'A', path: rel('mine.ts'), blob: blob('m') },
+ { status: 'A', path: rel('both.ts'), blob: blob('b') }
+ ],
+ generated: [{ status: 'A', path: rel('both.ts'), blob: blob('b') }]
+ })
+ );
+
+ expect(plan.files.kept.map(String)).to.deep.equal(['mine.ts']);
+ expect(plan.writes).to.deep.equal([]);
+ expect(plan.isEmpty()).to.be.true;
+ });
+});
From 7fcd38ff96205917d97bca17c3ab2ead77011af4 Mon Sep 17 00:00:00 2001
From: Shield-Jaguar
Date: Mon, 2 Nov 2026 23:18:29 +0500
Subject: [PATCH 04/28] fix(sdk): quote the paths in the git commands a merge
prints, for cmd.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
---
src/prompts/sdk/generate.ts | 40 ++++++++++++++-----------------
src/utils/string-utils.ts | 5 ++++
test/prompts/sdk/generate.test.ts | 4 ++--
3 files changed, 25 insertions(+), 24 deletions(-)
diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts
index bd440f5a0..af2336b55 100644
--- a/src/prompts/sdk/generate.ts
+++ b/src/prompts/sdk/generate.ts
@@ -1,6 +1,7 @@
import { isCancel, confirm, log } from '@clack/prompts';
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { FileProblem } from '../../types/file/file-problem.js';
+import { RelativeFilePath } from '../../types/file/relativeFilePath.js';
import { format as f } from '../format.js';
import { Result } from 'neverthrow';
import { noteWrapped, notZippedForUpload, withSpinner } from '../prompt.js';
@@ -10,11 +11,12 @@ import { GitVersion } from '../../types/git/git-version.js';
import { CommitId } from '../../types/git/commit-id.js';
import { Conflict, ConflictKind, MergeFiles } from '../../types/sdk/merge-plan.js';
import { WriteProblem } from '../../types/sdk/write-problem.js';
-import { listedInProse } from '../../utils/string-utils.js';
+import { listedInProse, plural } from '../../utils/string-utils.js';
const names = (languages: readonly Language[]) => languages.map(languageLabel).join(', ');
-const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`;
+const listed = (heading: string, paths: readonly RelativeFilePath[], sdk: DirectoryPath): string[] =>
+ paths.length === 0 ? [] : [heading, ...paths.map((path) => ` ${f.relativePath(path.in(sdk))}`)];
const CONFLICT_LABELS: Record = {
bothChanged: 'changed by you and by the generator',
@@ -27,15 +29,6 @@ const CONFLICT_LABELS: Record = {
const OPERATION_LABELS = { write: 'written', remove: 'removed' } as const;
-/** The generator's own files are a count, and so are the merged ones, which are also named below; the rest of yours are named. */
-const COUNTED_FILES = ['added', 'updated', 'merged', 'removed'] as const;
-
-/** The files of yours, each kind under its heading. */
-const NAMED_FILES = [
- ['merged', 'Merged with your changes:'],
- ['kept', 'Kept as yours:']
-] as const;
-
export class SdkGeneratePrompts {
/**
* Named rather than listed as flag values: a user whose language is on its way back reads
@@ -124,7 +117,7 @@ export class SdkGeneratePrompts {
/** The git found is older than the feature needs: the SDK is written as before, and edits to it will not survive the next run. */
public gitVersionNotSupported(found: GitVersion) {
log.warn(
- `git ${found.toString()} was found, but ${GitVersion.required()} or newer is needed, ` +
+ `git ${found} was found, but ${GitVersion.required()} or newer is needed, ` +
'so your customizations to the generated SDK will not persist across regenerations.'
);
}
@@ -173,10 +166,11 @@ export class SdkGeneratePrompts {
/** `--force` replaced an SDK with a generation record, which may have held customizations; git still has the committed ones. */
public recordedSdkReplaced(sdk: DirectoryPath) {
+ const diff = f.cmdAlt('git', '-C', `"${f.relative(sdk)}"`, 'diff');
log.warn(
`The new SDK could not be merged into ${f.relativePath(sdk)}, so ${f.flag('force')} replaced it, ` +
`and your customizations to it were not carried over. Those you committed are still in its git history, ` +
- `and git -C ${f.relativePath(sdk)} diff shows what changed; uncommitted ones are gone.`
+ `and ${diff} shows what changed; uncommitted ones are gone.`
);
}
@@ -207,10 +201,12 @@ export class SdkGeneratePrompts {
/** After a failed write, the directory could not be put back either: the command that does, from the snapshot kept before the write. */
public sdkNotRestored(sdk: DirectoryPath, previous: CommitId, reason: string) {
+ const quotedPath = `"${f.relative(sdk)}"`;
+ const restore = f.cmdAlt('git', '-C', quotedPath, 'restore', `--source=${previous}`, '--worktree', '--', '.');
log.error(
`The files written before it could not be put back either: ${reason}\n` +
`To restore ${f.relativePath(sdk)} as it was before this run:\n` +
- ` git -C ${f.relativePath(sdk)} restore --source=${previous} --worktree -- .`
+ ` ${restore}`
);
}
@@ -221,14 +217,14 @@ export class SdkGeneratePrompts {
/** The counts of what the generator did, and by name the files of yours it merged into or left alone. */
public sdkMerged(sdk: DirectoryPath, files: MergeFiles) {
- const counted = COUNTED_FILES.filter((kind) => files[kind].length > 0).map(
- (kind) => `${plural(files[kind].length, 'file')} ${kind}`
- );
- const named = NAMED_FILES.filter(([kind]) => files[kind].length > 0).flatMap(([kind, heading]) => [
- heading,
- ...files[kind].map((path) => ` ${f.relativePath(path.in(sdk))}`)
- ]);
- log.info([`Regenerated the SDK at ${f.relativePath(sdk)}: ${listedInProse(counted)}.`, ...named].join('\n'));
+ const counts = (['added', 'updated', 'merged', 'removed'] as const)
+ .filter((kind) => files[kind].length > 0)
+ .map((kind) => `${plural(files[kind].length, 'file')} ${kind}`);
+ const named = [
+ ...listed('Merged with your changes:', files.merged, sdk),
+ ...listed('Kept as yours:', files.kept, sdk)
+ ];
+ log.info([`Regenerated the SDK at ${f.relativePath(sdk)}: ${listedInProse(counts)}.`, ...named].join('\n'));
}
/** The SDK is merged and on disk; the record still names the last snapshot, which the next run merges against again. */
diff --git a/src/utils/string-utils.ts b/src/utils/string-utils.ts
index aa09c361d..6765590ee 100644
--- a/src/utils/string-utils.ts
+++ b/src/utils/string-utils.ts
@@ -25,3 +25,8 @@ const PROSE_LISTS = {
export function listedInProse(items: readonly string[], joiner: keyof typeof PROSE_LISTS = 'and'): string {
return PROSE_LISTS[joiner].format(items);
}
+
+/** `1 file`, `2 files`: a count with its noun, for nouns that pluralise with an `s`. */
+export function plural(count: number, noun: string): string {
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
+}
diff --git a/test/prompts/sdk/generate.test.ts b/test/prompts/sdk/generate.test.ts
index 4e7d28f76..ec5a6dd8a 100644
--- a/test/prompts/sdk/generate.test.ts
+++ b/test/prompts/sdk/generate.test.ts
@@ -274,7 +274,7 @@ describe('SdkGeneratePrompts, about a merge', () => {
const said = stripVTControlCharacters(warn.firstCall.args[0]);
expect(said).to.contain("The new SDK could not be merged into './sdk/python', so --force replaced it");
- expect(said).to.contain("git -C './sdk/python' diff");
+ expect(said).to.contain('git -C "./sdk/python" diff');
});
it('says that nothing was written when git could not work out the merge', () => {
@@ -317,7 +317,7 @@ describe('SdkGeneratePrompts, about a merge', () => {
expect(stripVTControlCharacters(error.firstCall.args[0])).to.contain('could not be removed: EBUSY: resource busy');
const said = stripVTControlCharacters(error.secondCall.args[0]);
expect(said).to.contain('The files written before it could not be put back either: EACCES on README.md');
- expect(said).to.contain(`git -C './sdk/python' restore --source=${'a'.repeat(40)} --worktree -- .`);
+ expect(said).to.contain(`git -C "./sdk/python" restore --source=${'a'.repeat(40)} --worktree -- .`);
});
it('says what the next run does when the snapshot of a merge is not recorded: merge against the previous one', () => {
From 2e65e2728afeda9363c6e9753bfd718b1abbc2ba Mon Sep 17 00:00:00 2001
From: Shield-Jaguar
Date: Mon, 2 Nov 2026 23:28:57 +0500
Subject: [PATCH 05/28] refactor: pluralise counts through one helper, and
refresh the skill 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
---
.ai/skills/action.md | 2 +-
.ai/skills/command.md | 2 +-
.ai/skills/prompt.md | 2 +-
src/infrastructure/generation-status-poller.ts | 3 ++-
src/prompts/plugin/publish.ts | 5 +++--
src/prompts/portal/generate.ts | 3 ++-
6 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/.ai/skills/action.md b/.ai/skills/action.md
index d815cfa12..91f058a63 100644
--- a/.ai/skills/action.md
+++ b/.ai/skills/action.md
@@ -72,7 +72,7 @@ Actions live at `src/actions/` and are the single use-case orchestrators for eac
| Standard action (auth + services + withDirPath) | `src/actions/api/validate.ts` |
| Standard action (multiple params + overwrite guard) | `src/actions/api/transform.ts` |
| Standard action (Context + TempContext + service) | `src/actions/portal/generate.ts` |
-| Standard action (SDK generation + version selection) | `src/actions/sdk/generate.ts` |
+| Standard action (SDK generation + git-backed regeneration) | `src/actions/sdk/generate.ts` |
| Standard action (gate + services + withDirPath) | `src/actions/portal/generate.ts` |
| Minimal action (configDir only, no services) | `src/actions/auth/logout.ts` |
| Minimal action (configDir + commandMetadata, no auth) | `src/actions/auth/status.ts` |
diff --git a/.ai/skills/command.md b/.ai/skills/command.md
index 740308c65..5d57e2db2 100644
--- a/.ai/skills/command.md
+++ b/.ai/skills/command.md
@@ -128,7 +128,7 @@ These three layers are always created together and mirror each other in path str
| Action with Result error handling | `src/actions/api/validate.ts` |
| Action with simpler constructor (no authKey) | `src/actions/auth/status.ts` |
| Action that delegates to sub-actions | `src/actions/quickstart.ts` |
-| Prompts with spinner + confirm + select | `src/prompts/sdk/generate.ts` |
+| Prompts with spinner + confirm + noteWrapped | `src/prompts/sdk/generate.ts` |
| Prompts with error display methods | `src/prompts/portal/generate.ts` |
| Prompts with format helpers | `src/prompts/api/transform.ts` |
| Prompts with noteWrapped + getTree | `src/prompts/quickstart.ts` |
diff --git a/.ai/skills/prompt.md b/.ai/skills/prompt.md
index abb56730b..2f657c3e4 100644
--- a/.ai/skills/prompt.md
+++ b/.ai/skills/prompt.md
@@ -65,7 +65,7 @@ Prompts live at `src/prompts/` and are the sole terminal UI layer for each comma
| Simple (log + spinner, no interactive prompts) | `src/prompts/auth/login.ts` |
| Simple (log-only display methods) | `src/prompts/auth/status.ts` |
| Standard (confirm + log + spinner) | `src/prompts/portal/generate.ts` |
-| Standard (confirm + select + log + spinner) | `src/prompts/sdk/generate.ts` |
+| Standard (confirm + log + spinner + noteWrapped) | `src/prompts/sdk/generate.ts` |
| Standard (log + spinner + structured display) | `src/prompts/api/validate.ts` |
| Delegation (select flow + welcome message) | `src/prompts/quickstart.ts` |
| Wizard (multi-step, noteWrapped, getTree) | `src/prompts/portal/quickstart.ts` |
diff --git a/src/infrastructure/generation-status-poller.ts b/src/infrastructure/generation-status-poller.ts
index 29d7aad0f..9d12b922f 100644
--- a/src/infrastructure/generation-status-poller.ts
+++ b/src/infrastructure/generation-status-poller.ts
@@ -1,6 +1,7 @@
import { Status } from '@apimatic/sdk';
import { err, ok, Result } from 'neverthrow';
import { REQUEST_TIMEOUT_MS } from '../config/axios-config.js';
+import { plural } from '../utils/string-utils.js';
import { replaceHTML } from '../utils/utils.js';
import { ServiceError } from './service-error.js';
import { sleep } from './timer-extensions.js';
@@ -92,7 +93,7 @@ const timedOutMessage = (label: string, budgetMs: number): string => {
if (minutes < 1) {
return `${label} timed out.`;
}
- return `${label} timed out after ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.`;
+ return `${label} timed out after ${plural(minutes, 'minute')}.`;
};
// The server writes its messages as HTML; the terminal gets their text.
diff --git a/src/prompts/plugin/publish.ts b/src/prompts/plugin/publish.ts
index a651457b3..f48871cc7 100644
--- a/src/prompts/plugin/publish.ts
+++ b/src/prompts/plugin/publish.ts
@@ -6,6 +6,7 @@ import { PluginReleaseData } from '../../types/plugin-config-context.js';
import { format as f } from '../format.js';
import { noteWrapped } from '../prompt.js';
import { APIMATIC_CONFIG_FILE_NAME } from '../../types/apimatic-config/document.js';
+import { plural } from '../../utils/string-utils.js';
const NOTE_TITLE = 'Commands to Copy and Run';
const TAG_PREFIX = 'v';
@@ -73,8 +74,8 @@ export class PluginPublishPrompts {
}
private contentsLine(contents: PluginContents): string {
- const files = `${contents.fileCount} ${contents.fileCount === 1 ? 'file' : 'files'}`;
- const directories = `${contents.directoryCount} ${contents.directoryCount === 1 ? 'dir' : 'dirs'}`;
+ const files = plural(contents.fileCount, 'file');
+ const directories = plural(contents.directoryCount, 'dir');
return `What is about to become public: ${files}, ${directories}`;
}
diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts
index 58f4d49e6..cd8903f9b 100644
--- a/src/prompts/portal/generate.ts
+++ b/src/prompts/portal/generate.ts
@@ -7,6 +7,7 @@ import { SiteAddress } from '../../types/portal/config/site-address.js';
import { Result } from 'neverthrow';
import { format as f } from '../format.js';
import { logTail, noteWrapped, withSpinner } from '../prompt.js';
+import { plural } from '../../utils/string-utils.js';
function describeSaveProblem(problem: PortalSaveProblem): string {
switch (problem.kind) {
@@ -61,7 +62,7 @@ export class PortalGeneratePrompts {
public buildPortal(fn: Promise>) {
return withSpinner(
'Building the portal',
- ({ pageCount }) => `Built ${pageCount} ${pageCount === 1 ? 'page' : 'pages'}.`,
+ ({ pageCount }) => `Built ${plural(pageCount, 'page')}.`,
(failure) => failure.message,
fn,
{ indicator: 'timer' }
From b2af733c4caf06e858d5f8f10a04ab770c86c9a8 Mon Sep 17 00:00:00 2001
From: Shield-Jaguar
Date: Tue, 3 Nov 2026 09:55:28 +0500
Subject: [PATCH 06/28] feat(sdk): ask how a regeneration's conflicts are
resolved
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
---
.ai/instructions.md | 4 +-
.ai/plans/custom-code-phases.md | 40 ++-
.ai/skills/event.md | 4 +-
src/actions/action-result.ts | 50 +++-
src/actions/sdk/generate.ts | 38 ++-
src/actions/sdk/publish.ts | 15 +-
src/actions/sdk/publish/interactive.ts | 7 +-
src/actions/sdk/publish/non-interactive.ts | 7 +-
src/commands/quickstart.ts | 11 +-
src/infrastructure/git-service.ts | 23 +-
src/prompts/format.ts | 14 +-
src/prompts/sdk/generate.ts | 143 ++++++++--
src/prompts/sdk/publish.ts | 5 +
src/types/common/value-map.ts | 3 +
src/types/git-context.ts | 23 +-
src/types/git/merge-tree.ts | 3 +
src/types/sdk/conflict-markers.ts | 93 ++++++
src/types/sdk/conflict.ts | 166 +++++++++++
src/types/sdk/file-content.ts | 6 +-
src/types/sdk/merge-plan.ts | 165 +++++++----
test/actions/action-result.test.ts | 28 ++
test/actions/sdk/generate-merge.test.ts | 190 ++++++++++++-
test/actions/sdk/publish.test.ts | 58 ++--
test/helpers/merge.ts | 47 ++++
test/helpers/printed.ts | 13 +
test/infrastructure/git-service.test.ts | 73 ++++-
test/prompts/format.test.ts | 19 +-
test/prompts/sdk/generate.test.ts | 164 ++++++++---
test/prompts/sdk/publish.test.ts | 21 ++
test/types/git-context.test.ts | 224 ++++++++++++---
test/types/sdk/conflict-markers.test.ts | 111 ++++++++
test/types/sdk/conflict.test.ts | 110 ++++++++
test/types/sdk/merge-plan.test.ts | 312 +++++++++++++++++----
33 files changed, 1894 insertions(+), 296 deletions(-)
create mode 100644 src/types/sdk/conflict-markers.ts
create mode 100644 src/types/sdk/conflict.ts
create mode 100644 test/helpers/merge.ts
create mode 100644 test/helpers/printed.ts
create mode 100644 test/prompts/sdk/publish.test.ts
create mode 100644 test/types/sdk/conflict-markers.test.ts
create mode 100644 test/types/sdk/conflict.test.ts
diff --git a/.ai/instructions.md b/.ai/instructions.md
index 71fcd03bd..c83da2dd4 100644
--- a/.ai/instructions.md
+++ b/.ai/instructions.md
@@ -92,7 +92,7 @@ Supporting: **Types** (`src/types/`) for value objects, context objects, and dom
- **Temp directories** — always use `withDirPath()` wrapper, never `tmp-promise` directly
- **`authKey` typed as `string | null = null`**, not `undefined`
- **Exit via `outro(result)`** — sets `process.exitCode`; never call `process.exit()` directly
-- **`ActionResult` variants** — `success()`, `failed()`, `cancelled()`, `stopped()` (for long-running server commands); exit codes 0 / 1 / 130 respectively
+- **`ActionResult` variants** — `success()`, `failed()`, `cancelled()`, `stopped()` (for long-running server commands), `actionRequired()` (the developer has something left to do, such as conflicts to resolve; yellow outro); exit codes 0 / 1 / 130 / 130 / 1 respectively
- **Constructor pattern**: `private readonly` properties, `public readonly execute = async (...) => { ... }`
- **`static cmdTxt`** on every Command using `format.cmd(...)` for example rendering
- **Commands use `export default class`** — oclif requires default export; actions, prompts, and services use named exports (`export class`)
@@ -102,7 +102,7 @@ Supporting: **Types** (`src/types/`) for value objects, context objects, and dom
- **Reuse infra** — network, file I/O, OS calls, sleep, time: use existing services, Node.js standard libraries and built-in JS APIs.
- **Readability is king** — simple names, simple domain models, composed with simple functions that turn one type into another instead of passing primitives around.
- **Validate once, up front** — no repeated runtime type checks; later code works with guaranteed types.
-- **Telemetry** — After `outro(result)`, commands optionally track failures via `result.mapAll(() => {}, async () => { await new TelemetryService(configDir).trackEvent(new SomeFailedEvent(...), shell) }, () => {})`. Event classes extend `DomainEvent` (`src/types/events/`). Only the failure callback is populated; success/cancel are no-ops.
+- **Telemetry** — After `outro(result)`, commands optionally track failures via `result.match({ success: () => {}, failed: async () => { await new TelemetryService(configDir).trackEvent(new SomeFailedEvent(...), shell) }, cancelled: () => {}, actionRequired: () => {} })`. Event classes extend `DomainEvent` (`src/types/events/`). Only the `failed` handler is populated; the others are no-ops.
## Branching
diff --git a/.ai/plans/custom-code-phases.md b/.ai/plans/custom-code-phases.md
index e3bd69146..276416770 100644
--- a/.ai/plans/custom-code-phases.md
+++ b/.ai/plans/custom-code-phases.md
@@ -241,8 +241,34 @@ Behaviour:
not be marked, warn and leave the merge tool out.
5. The run ends as Action required: a yellow outro, exit 1, so a chained command does not run.
+Done, in one slice. `ActionResult.actionRequired()` and `isActionRequired()`, the yellow outro,
+and `match({ success, failed, cancelled, actionRequired })` in place of `mapAll`;
+`GitService.stageConflicts` (a stage-0 entry dropped first, then every stage merge-tree
+reported, through `update-index --index-info`), reached through `GitContext.stageConflicts(plan)`
+like every other git operation on the SDK directory; `merge.directoryRenames=conflict` pinned
+beside `merge.conflictStyle`, so a file added inside a directory the generator renamed follows
+it whatever the developer's global config says; `MergedText` in `conflict-markers.ts` (git's
+labels read, `id` and `id:path` alike, relabelled with the CLI's, settled for a side line by
+line, a malformed sequence leaving the file whole); `conflict.ts` with `Settlement` (how one
+conflict lands for a side: what is written and what the summary calls it) and three conflicts
+behind one interface, each carrying the stage entries git kept for its path, `MarkedConflict`
+for the files git marked, `WholeConflict` for a file a side deleted or a binary one (the
+generator's version where it has one, else yours stays), `PlacedConflict` for the other shapes
+(git's placement stands); `MergePlan.taking(side)` and `stages` derived from the conflicts,
+built from the stage-3 blobs `GitContext.merge` reads beside the merged tree. The only switch
+on a conflict's kind is where git's facts become objects. A path git names in a conflict
+message that the merge leaves nowhere (neither staged nor written), such as the old name of a
+file the generator renamed and you deleted, or of one the merge moved into a directory the
+generator renamed, is left out of the conflicts and of the summary: it is no file to settle.
+The `conflictChoice` (answering a side, `asPlaced` for the files as git left them, or `cancel`,
+which backing out answers too), `conflictsResolved`, `conflictSteps`, `conflictsNotStaged` and
+`runCancelled` prompts; `conflictsNotAsked` titled "N files need your attention" and saying how
+to rerun. Phase 7's item 3 was pulled forward: `sdk publish` says `conflictsNotPublished` and
+ends as Action required when the generation does.
+
Touches: `ActionResult.actionRequired()` and `isActionRequired()`, the yellow outro and the
-fourth `mapAll` callback; `GitService.stageConflicts`; conflict kinds completed (both changed,
+fourth `match` handler; `GitService.stageConflicts` and `GitContext.stageConflicts`;
+`merge.directoryRenames` pinned; conflict kinds completed (both changed,
both added, deleted by you, no longer generated, binary, other), settling markers for a side,
the plan with a side taken; the `conflictChoice`, `conflictsResolved`, `conflictSteps`,
`conflictsNotStaged` and `runCancelled` prompts.
@@ -250,8 +276,11 @@ the plan with a side taken; the `conflictChoice`, `conflictsResolved`, `conflict
Tests from `92988f2f`: generate-write "the question when the merge conflicts" group, "writes
markers …", "leaves the merge tool out …"; the merge table's marker cases, "settled for a side"
group, the binary conflict and the two-spec conflicts; `conflict-markers.test.ts`,
-`settlement.test.ts`, `merge-plan.test.ts` for `taking`; `action-result.test.ts`,
-`format.test.ts`; the prompts tests about the conflicts.
+`settlement.test.ts` (folded into `merge-plan.test.ts`, since the settlement is
+`MergePlan.taking`), `merge-plan.test.ts` for `taking`; `action-result.test.ts`,
+`format.test.ts`; the prompts tests about the conflicts; `conflict.test.ts` for the three
+conflicts and `Settlement`; git-service `stageConflicts`; publish "publishes nothing, and ends
+as Action required …".
### Phase 4: the next run after conflicts were left
@@ -348,7 +377,7 @@ Behaviour:
own ignore rules keep out. Outside a repository of its own, everything but `.git` and
`.apimatic`.
3. A generation that ends as Action required publishes nothing, and says the SDK still has
- conflicts.
+ conflicts. Done in phase 3.
Touches: `GitService.ignoredBelow`; `RelativeDirectoryPath` and `RelativePaths`;
`ZipService.archiveWithout` and `TempContext.zipWithout`; the `--git-init` flag on publish and
@@ -423,7 +452,8 @@ write fails.
- A new `ActionRequired` kind with exit 1 and a yellow outro, as the product review chose. For:
not a success, since the SDK will not build; not a failure, since the run did what was
- chosen. Against: every `mapAll` caller gains a callback.
+ chosen. Against: every `mapAll` caller gains a callback. Taken, with `mapAll` renamed
+ `match` over named handlers, so a caller that leaves one out fails to compile.
- Reuse `failed` with a message. For: no new kind. Against: `sdk publish` could not tell a
conflict from a failure, and the outro would be red for a run that did what was asked.
diff --git a/.ai/skills/event.md b/.ai/skills/event.md
index 587d23f8f..c01007696 100644
--- a/.ai/skills/event.md
+++ b/.ai/skills/event.md
@@ -73,7 +73,7 @@ export class {PascalName}Event extends DomainEvent {
- **DO** place the file at `src/types/events/{kebab-name}.ts`.
- **DO** use `.js` extension in the import: `import { DomainEvent } from "./domain-event.js"`.
- **DO** fire events from the Command layer only — never from Actions or Services.
-- **DO** fire success events inside the `mapAll` success callback (first arg); failure events in the failure callback (second arg).
+- **DO** fire success events inside the `success` handler of `result.match(...)`; failure events in its `failed` handler.
### DON'T
@@ -92,7 +92,7 @@ export class {PascalName}Event extends DomainEvent {
- [ ] `message` and `commandName` are `private static readonly ... as const` (self-contained variant)
- [ ] Named export (not default)
- [ ] Import uses `.js` extension: `"./domain-event.js"`
-- [ ] Fired from Command layer only, inside `result.mapAll(...)` callback
+- [ ] Fired from Command layer only, inside a `result.match(...)` handler
---
diff --git a/src/actions/action-result.ts b/src/actions/action-result.ts
index 8f9df03cf..7a956b703 100644
--- a/src/actions/action-result.ts
+++ b/src/actions/action-result.ts
@@ -1,9 +1,29 @@
enum ResultType {
- Success = 0,
- Cancel = 130,
- Failure = 1
+ Success,
+ Failure,
+ Cancel,
+ ActionRequired
}
+/**
+ * A run that needs the developer to act exits as `git merge` does on conflicts: its work is not done,
+ * so whatever is chained after it should not run. Two kinds share a code, so the codes are kept apart from the kinds.
+ */
+const EXIT_CODES: Record = {
+ [ResultType.Success]: 0,
+ [ResultType.Failure]: 1,
+ [ResultType.Cancel]: 130,
+ [ResultType.ActionRequired]: 1
+};
+
+/** One handler per way a run can end. */
+export type ResultHandlers = {
+ success: (value?: T) => R;
+ failed: () => R;
+ cancelled: () => R;
+ actionRequired: () => R;
+};
+
export class ActionResult {
private readonly message: string;
private readonly resultType: ResultType;
@@ -33,12 +53,17 @@ export class ActionResult {
return new ActionResult(ResultType.Cancel, message);
}
+ /** The run ended with something the developer still has to do, such as files to resolve. */
+ static actionRequired(message = 'Action required'): ActionResult {
+ return new ActionResult(ResultType.ActionRequired, message);
+ }
+
public getMessage(): string {
return this.message;
}
public getExitCode(): number {
- return this.resultType.valueOf();
+ return EXIT_CODES[this.resultType];
}
public isFailed(): boolean {
@@ -53,6 +78,10 @@ export class ActionResult {
return this.resultType === ResultType.Cancel;
}
+ public isActionRequired(): boolean {
+ return this.resultType === ResultType.ActionRequired;
+ }
+
public getValue(): T {
if (!this.isSuccess()) {
throw new Error(`Cannot unwrap ${ResultType[this.resultType]} result: ${this.message}`);
@@ -64,18 +93,21 @@ export class ActionResult {
return this.error;
}
- public discardValue(): ActionResult {
+ /** The same outcome with nothing attached, typed `never` so it can stand in for any `ActionResult` a caller passes on. */
+ public discardValue(): ActionResult {
return new ActionResult(this.resultType, this.message);
}
- public mapAll(onSuccess: (value?: T) => R, onFailure: () => R, onCancel: () => R): R {
+ public match(handlers: ResultHandlers): R {
switch (this.resultType) {
case ResultType.Success:
- return onSuccess(this.value);
+ return handlers.success(this.value);
case ResultType.Failure:
- return onFailure();
+ return handlers.failed();
case ResultType.Cancel:
- return onCancel();
+ return handlers.cancelled();
+ case ResultType.ActionRequired:
+ return handlers.actionRequired();
}
}
}
diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts
index eae4b75e6..f6e4ec8e0 100644
--- a/src/actions/sdk/generate.ts
+++ b/src/actions/sdk/generate.ts
@@ -2,7 +2,7 @@ import { SdkGenerationService } from '../../infrastructure/services/sdk-generati
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { ActionResult } from '../action-result.js';
import { withDirPath } from '../../infrastructure/tmp-extensions.js';
-import { SdkGeneratePrompts } from '../../prompts/sdk/generate.js';
+import { ConflictChoice, SdkGeneratePrompts } from '../../prompts/sdk/generate.js';
import { CommandMetadata } from '../../types/common/command-metadata.js';
import { TempContext } from '../../types/temp-context.js';
import { isAvailableLanguage, Language, Stability } from '../../types/sdk/generate.js';
@@ -138,19 +138,30 @@ export class GenerateAction {
this.prompts.customCodeFailed(mergePlan.error);
return ActionResult.failed();
}
- if (mergePlan.value.hasConflicts()) {
- this.prompts.conflictsNotAsked(sdk, mergePlan.value.conflicts);
+ const plan = mergePlan.value;
+ if (plan.hasConflicts() && !force && !canAsk) {
+ this.prompts.conflictsNotAsked(sdk, plan.conflicts);
return ActionResult.failed();
}
+ const choice: ConflictChoice = !plan.hasConflicts()
+ ? 'asPlaced'
+ : force
+ ? 'generated'
+ : await this.prompts.conflictChoice(sdk, plan.conflicts);
+ if (choice === 'cancel') {
+ this.prompts.runCancelled(sdk);
+ return ActionResult.cancelled();
+ }
+ const landing = choice === 'asPlaced' ? plan : plan.taking(choice);
const previous = await gitContext.keepPrevious(mergeBaseCommitId);
if (previous.isErr()) {
this.prompts.customCodeFailed(previous.error);
return ActionResult.failed();
}
- const written = await gitContext.write(mergePlan.value, generated.value);
+ const written = await gitContext.write(landing, generated.value);
if (written.isErr()) {
this.prompts.sdkNotWritten(sdk, written.error);
- const restored = await gitContext.restore(previous.value, mergePlan.value);
+ const restored = await gitContext.restore(previous.value, landing);
if (restored.isErr()) {
this.prompts.sdkNotRestored(sdk, previous.value, restored.error);
} else {
@@ -162,12 +173,23 @@ export class GenerateAction {
if (recorded.isErr()) {
this.prompts.snapshotNotRecordedAfterMerge(recorded.error);
}
- if (mergePlan.value.isEmpty()) {
+ if (choice !== 'asPlaced') {
+ this.prompts.conflictsResolved(sdk, choice, plan.conflicts);
+ }
+ if (landing.isEmpty()) {
this.prompts.sdkUnchanged(sdk);
} else {
- this.prompts.sdkMerged(sdk, mergePlan.value.files);
+ this.prompts.sdkMerged(sdk, landing);
+ }
+ if (!landing.hasConflicts()) {
+ return ActionResult.success();
+ }
+ const staged = await gitContext.stageConflicts(landing);
+ if (staged.isErr()) {
+ this.prompts.conflictsNotStaged(staged.error);
}
- return ActionResult.success();
+ this.prompts.conflictSteps(sdk, landing, staged.isOk());
+ return ActionResult.actionRequired();
}
// The directory becomes a repository only once there is a generation to put in it, so a failed generation leaves none behind.
diff --git a/src/actions/sdk/publish.ts b/src/actions/sdk/publish.ts
index 6664d65d4..5ab6f3cb7 100644
--- a/src/actions/sdk/publish.ts
+++ b/src/actions/sdk/publish.ts
@@ -62,11 +62,11 @@ export class SdkPublishAction {
false,
packageSettingsDirectory
);
- if (sdkGenerationResult.isFailed()) {
- return ActionResult.failed();
+ if (sdkGenerationResult.isActionRequired()) {
+ this.prompts.conflictsNotPublished();
}
- if (sdkGenerationResult.isCancelled()) {
- return ActionResult.cancelled();
+ if (!sdkGenerationResult.isSuccess()) {
+ return sdkGenerationResult.discardValue();
}
if (dryRun) {
@@ -104,11 +104,8 @@ export class SdkPublishAction {
return ActionResult.success(publishSdkResponse.value);
});
- if (publishResult.isFailed()) {
- return ActionResult.failed();
- }
- if (publishResult.isCancelled()) {
- return ActionResult.cancelled();
+ if (!publishResult.isSuccess()) {
+ return publishResult.discardValue();
}
// Since publishing was not initiated, skip the next steps for polling.
diff --git a/src/actions/sdk/publish/interactive.ts b/src/actions/sdk/publish/interactive.ts
index 8c9a469a7..465f15101 100644
--- a/src/actions/sdk/publish/interactive.ts
+++ b/src/actions/sdk/publish/interactive.ts
@@ -140,11 +140,8 @@ export class SdkPublishInteractiveAction {
publishingSummary,
onPublishSdkError
);
- if (publishResult.isFailed()) {
- return ActionResult.failed();
- }
- if (publishResult.isCancelled()) {
- return ActionResult.cancelled();
+ if (!publishResult.isSuccess()) {
+ return publishResult.discardValue();
}
await new RecordPublishedSdkAction().execute(project, language, publishingProfile, publishTypes, version);
diff --git a/src/actions/sdk/publish/non-interactive.ts b/src/actions/sdk/publish/non-interactive.ts
index e81f78411..9aa5e868d 100644
--- a/src/actions/sdk/publish/non-interactive.ts
+++ b/src/actions/sdk/publish/non-interactive.ts
@@ -123,11 +123,8 @@ export class SdkPublishNonInteractiveAction {
publishingSummary,
onPublishSdkError
);
- if (publishResult.isFailed()) {
- return ActionResult.failed();
- }
- if (publishResult.isCancelled()) {
- return ActionResult.cancelled();
+ if (!publishResult.isSuccess()) {
+ return publishResult.discardValue();
}
// A dry run publishes nothing, so recording it would claim an SDK that does not exist anywhere.
diff --git a/src/commands/quickstart.ts b/src/commands/quickstart.ts
index 6e33a967b..54f87ac0e 100644
--- a/src/commands/quickstart.ts
+++ b/src/commands/quickstart.ts
@@ -32,11 +32,12 @@ export default class Quickstart extends Command {
outro(result);
// TODO: Remove this, find a solution for tracking.
- await result.mapAll(
- async () => await telemetryService.trackEvent(new QuickstartCompletedEvent(), commandMetadata.shell),
- async () => {},
- async () => {}
- );
+ await result.match({
+ success: async () => await telemetryService.trackEvent(new QuickstartCompletedEvent(), commandMetadata.shell),
+ failed: async () => {},
+ cancelled: async () => {},
+ actionRequired: async () => {}
+ });
}
private readonly getConfigDir = () => {
diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts
index 3b5933de5..d31ce0502 100644
--- a/src/infrastructure/git-service.ts
+++ b/src/infrastructure/git-service.ts
@@ -10,9 +10,10 @@ import { RelativeFilePath } from '../types/file/relativeFilePath.js';
import { BlobId } from '../types/git/blob-id.js';
import { CommitId } from '../types/git/commit-id.js';
import { GitVersion } from '../types/git/git-version.js';
-import { Change, MergeTreeOutput, parseMergeTree } from '../types/git/merge-tree.js';
+import { Change, MergeTreeOutput, parseMergeTree, StageEntry } from '../types/git/merge-tree.js';
import { TreeEntry } from '../types/git/tree-entry.js';
import { TreeId } from '../types/git/tree-id.js';
+import { ValueMap } from '../types/common/value-map.js';
import { errorMessage } from '../utils/error-utils.js';
import { canonical, withDirPath } from './tmp-extensions.js';
@@ -183,6 +184,25 @@ export class GitService {
});
}
+ /**
+ * Puts the paths into the repository's own index as a merge would leave them, so `git status` and a
+ * merge tool see them; a stage-0 entry is dropped first, or git keeps it beside the others.
+ */
+ public async stageConflicts(root: DirectoryPath, entries: readonly StageEntry[]): Promise> {
+ if (entries.length === 0) {
+ return ok(undefined);
+ }
+ // A mode-0 line drops every stage of its path; git only asks that its id be well formed, so the path's own serves.
+ const byPath = new ValueMap(entries.map((entry) => [entry.path, entry] as const));
+ const removals = byPath.values().map((entry) => `0 ${entry.oid}\t${entry.path}`);
+ const stages = entries.map((entry) => `${entry.mode} ${entry.oid} ${entry.stage}\t${entry.path}`);
+ const ran = await run(['update-index', '-z', '--index-info'], {
+ cwd: root,
+ input: [...removals, ...stages].map((line) => `${line}\0`).join('')
+ });
+ return ran.map(() => undefined);
+ }
+
/** How `to` differs from `from`, file by file; a moved file is a deletion and an addition. */
public diffTrees(
root: DirectoryPath,
@@ -210,6 +230,7 @@ const SETTINGS: readonly (readonly [string, string])[] = [
['core.safecrlf', 'false'],
['core.quotePath', 'false'],
['merge.conflictStyle', 'merge'],
+ ['merge.directoryRenames', 'conflict'],
['merge.renormalize', 'true'],
['merge.renameLimit', '10000'],
['diff.renameLimit', '10000'],
diff --git a/src/prompts/format.ts b/src/prompts/format.ts
index 3cde32ba2..f68f74809 100644
--- a/src/prompts/format.ts
+++ b/src/prompts/format.ts
@@ -35,7 +35,8 @@ export const format = {
intro: (text: string) => pc.bgCyan(text),
outroSuccess: (text: string) => pc.bgGreen(text),
outroFailure: (text: string) => pc.bgRed(text),
- outroCancelled: (text: string) => pc.bgWhite(pc.blackBright(text))
+ outroCancelled: (text: string) => pc.bgWhite(pc.blackBright(text)),
+ outroActionRequired: (text: string) => pc.bgYellow(pc.black(text))
};
export function intro(text: string) {
@@ -45,11 +46,12 @@ export function outro(result: ActionResult) {
const exitCode = result.getExitCode();
const message = result.getMessage();
- const outroMessage = result.mapAll(
- () => format.outroSuccess(message),
- () => format.outroFailure(message),
- () => format.outroCancelled(message)
- );
+ const outroMessage = result.match({
+ success: () => format.outroSuccess(message),
+ failed: () => format.outroFailure(message),
+ cancelled: () => format.outroCancelled(message),
+ actionRequired: () => format.outroActionRequired(message)
+ });
o(outroMessage);
process.exitCode = exitCode;
diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts
index af2336b55..9de565c1c 100644
--- a/src/prompts/sdk/generate.ts
+++ b/src/prompts/sdk/generate.ts
@@ -1,7 +1,6 @@
-import { isCancel, confirm, log } from '@clack/prompts';
+import { isCancel, confirm, log, select } from '@clack/prompts';
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { FileProblem } from '../../types/file/file-problem.js';
-import { RelativeFilePath } from '../../types/file/relativeFilePath.js';
import { format as f } from '../format.js';
import { Result } from 'neverthrow';
import { noteWrapped, notZippedForUpload, withSpinner } from '../prompt.js';
@@ -9,14 +8,23 @@ import { ServiceError } from '../../infrastructure/service-error.js';
import { AVAILABLE_LANGUAGES, Language, languageLabel, UPCOMING_LANGUAGES } from '../../types/sdk/generate.js';
import { GitVersion } from '../../types/git/git-version.js';
import { CommitId } from '../../types/git/commit-id.js';
-import { Conflict, ConflictKind, MergeFiles } from '../../types/sdk/merge-plan.js';
+import { RelativeFilePath } from '../../types/file/relativeFilePath.js';
+import { Side } from '../../types/sdk/conflict-markers.js';
+import { Conflict, ConflictKind } from '../../types/sdk/conflict.js';
+import { MergePlan } from '../../types/sdk/merge-plan.js';
import { WriteProblem } from '../../types/sdk/write-problem.js';
import { listedInProse, plural } from '../../utils/string-utils.js';
+/** What the developer chose for the conflicts: a side to settle them for, the files as git placed them to resolve by hand, or to stop. */
+export type ConflictChoice = Side | 'asPlaced' | 'cancel';
+
const names = (languages: readonly Language[]) => languages.map(languageLabel).join(', ');
-const listed = (heading: string, paths: readonly RelativeFilePath[], sdk: DirectoryPath): string[] =>
- paths.length === 0 ? [] : [heading, ...paths.map((path) => ` ${f.relativePath(path.in(sdk))}`)];
+/** The words that change between one conflicted file and several. */
+const forms = (count: number) =>
+ count === 1
+ ? { them: 'it', theyAre: 'it is', files: 'file', need: 'needs' }
+ : { them: 'them', theyAre: 'they are', files: 'files', need: 'need' };
const CONFLICT_LABELS: Record = {
bothChanged: 'changed by you and by the generator',
@@ -27,8 +35,33 @@ const CONFLICT_LABELS: Record = {
other: 'placed by git where it could, with both versions on disk'
};
+const RESOLVED_HEADINGS: Record = {
+ generated: "Resolved using the generator's changes:",
+ yours: 'Resolved using your changes:'
+};
+
const OPERATION_LABELS = { write: 'written', remove: 'removed' } as const;
+/** The generator's own files are a count, and so are the merged ones, which are also named below; the rest of yours are named. */
+const COUNTED_FILES = ['added', 'updated', 'merged', 'removed'] as const;
+
+/** The files of yours, each kind under its heading. */
+const NAMED_FILES = [
+ ['merged', 'Merged with your changes:'],
+ ['kept', 'Kept as yours:']
+] as const;
+
+const attentionTitle = (conflicts: readonly Conflict[]) =>
+ `${plural(conflicts.length, 'file')} ${forms(conflicts.length).need} your attention`;
+
+const conflictLines = (sdk: DirectoryPath, conflicts: readonly Conflict[]) =>
+ conflicts.map((conflict) => `${f.relativePath(conflict.path.in(sdk))}\n ${CONFLICT_LABELS[conflict.kind]}`);
+
+const listed = (sdk: DirectoryPath, paths: readonly RelativeFilePath[]) =>
+ paths.map((path) => ` ${f.relativePath(path.in(sdk))}`);
+
+const pathsOf = (conflicts: readonly Conflict[]) => conflicts.map((conflict) => conflict.path);
+
export class SdkGeneratePrompts {
/**
* Named rather than listed as flag values: a user whose language is on its way back reads
@@ -179,12 +212,50 @@ export class SdkGeneratePrompts {
log.error(`Your customizations could not be merged into the regenerated SDK: ${reason}\nNothing was written.`);
}
- /** The conflicted files, with what each one is, for a run that writes none of them. */
+ /** The conflicted files, with what each one is, for a run that cannot ask about them and so writes none. */
public conflictsNotAsked(sdk: DirectoryPath, conflicts: readonly Conflict[]) {
- const lines = conflicts.map(
- (conflict) => `${f.relativePath(conflict.path.in(sdk))}\n ${CONFLICT_LABELS[conflict.kind]}`
- );
- noteWrapped([...lines, '', 'Nothing was written.'].join('\n'), `Conflicts in ${plural(conflicts.length, 'file')}`);
+ const { them, theyAre } = forms(conflicts.length);
+ const howToRerun =
+ `Nothing was written. Rerun in a terminal to choose how ${theyAre} resolved, ` +
+ `or with ${f.flag('force')} to resolve ${them} using the generator's changes.`;
+ noteWrapped([...conflictLines(sdk, conflicts), '', howToRerun].join('\n'), attentionTitle(conflicts));
+ }
+
+ /** The conflicted files, with what each one is, then the four ways the run can go on; backing out is cancelling. */
+ public async conflictChoice(sdk: DirectoryPath, conflicts: readonly Conflict[]): Promise {
+ const { them, files } = forms(conflicts.length);
+ noteWrapped(conflictLines(sdk, conflicts).join('\n'), attentionTitle(conflicts));
+ const choice = await select({
+ message: 'What should happen?',
+ options: [
+ {
+ value: 'asPlaced',
+ label: `I'll resolve ${them} myself`,
+ hint: `markers go into the ${files}; resolve and stage ${them}, then rerun`
+ },
+ {
+ value: 'generated',
+ label: 'Resolve using generator changes',
+ hint: "where you both changed the same lines, the generator's lines win; your other edits stay"
+ },
+ {
+ value: 'yours',
+ label: 'Resolve using my changes',
+ hint: "where you both changed the same lines, your lines win; the generator's other changes still apply"
+ },
+ { value: 'cancel', label: 'Cancel this run', hint: 'nothing is written; the SDK stays as it is' }
+ ]
+ });
+ return isCancel(choice) ? 'cancel' : choice;
+ }
+
+ public runCancelled(sdk: DirectoryPath) {
+ log.info(`Nothing was written; ${f.relativePath(sdk)} is as it was.`);
+ }
+
+ /** The files the run settled for `side`, named before the summary counts them. */
+ public conflictsResolved(sdk: DirectoryPath, side: Side, conflicts: readonly Conflict[]) {
+ log.info([RESOLVED_HEADINGS[side], ...listed(sdk, pathsOf(conflicts))].join('\n'));
}
/** The file the write stopped at, and why. */
@@ -215,16 +286,50 @@ export class SdkGeneratePrompts {
log.info(`Regenerated the SDK at ${f.relativePath(sdk)}: nothing changed.`);
}
- /** The counts of what the generator did, and by name the files of yours it merged into or left alone. */
- public sdkMerged(sdk: DirectoryPath, files: MergeFiles) {
- const counts = (['added', 'updated', 'merged', 'removed'] as const)
- .filter((kind) => files[kind].length > 0)
- .map((kind) => `${plural(files[kind].length, 'file')} ${kind}`);
- const named = [
- ...listed('Merged with your changes:', files.merged, sdk),
- ...listed('Kept as yours:', files.kept, sdk)
+ /** The counts of what the generator did, and of what is left in conflict, and by name the files of yours it merged into or left alone. */
+ public sdkMerged(sdk: DirectoryPath, plan: MergePlan) {
+ const { files } = plan;
+ const counted = [
+ ...COUNTED_FILES.filter((kind) => files[kind].length > 0).map(
+ (kind) => `${plural(files[kind].length, 'file')} ${kind}`
+ ),
+ ...(plan.hasConflicts() ? [`${plural(plan.conflicts.length, 'file')} in conflict`] : [])
+ ];
+ const named = NAMED_FILES.filter(([kind]) => files[kind].length > 0).flatMap(([kind, heading]) => [
+ heading,
+ ...listed(sdk, files[kind])
+ ]);
+ log.info([`Regenerated the SDK at ${f.relativePath(sdk)}: ${listedInProse(counted)}.`, ...named].join('\n'));
+ }
+
+ /** The conflicted files could not be put in the index, so the next run will have no sign of them. */
+ public conflictsNotStaged(reason: string) {
+ log.warn(
+ `The conflicted files could not be marked in git's index: ${reason}\n` +
+ 'The markers are in the files, but the next run will not know about them: resolve them before rerunning.'
+ );
+ }
+
+ /** How to finish a merge left to the developer: a merge tool only where git holds the files as unmerged, the files with markers and without apart, then a rerun. */
+ public conflictSteps(sdk: DirectoryPath, plan: MergePlan, staged: boolean) {
+ const { them } = forms(plan.conflicts.length);
+ const tool = staged ? `in a merge tool, such as ${f.var('git mergetool')} or your editor's merge view, or ` : '';
+ const byHand = [
+ {
+ conflicts: plan.conflicts.filter((conflict) => conflict.leavesMarkers()),
+ what: 'With markers: keep what is right, remove the markers, and stage each file.'
+ },
+ {
+ conflicts: plan.conflicts.filter((conflict) => !conflict.leavesMarkers()),
+ what: 'Without markers: decide whether each file stays or goes (delete it again, or keep it as your own), and stage that.'
+ }
+ ].flatMap(({ conflicts, what }) => (conflicts.length === 0 ? [] : [what, ...listed(sdk, pathsOf(conflicts))]));
+ const steps = [
+ `1. Resolve ${them} ${tool}by hand:`,
+ ...byHand.map((line) => ` ${line}`),
+ '2. Rerun the command.'
];
- log.info([`Regenerated the SDK at ${f.relativePath(sdk)}: ${listedInProse(counts)}.`, ...named].join('\n'));
+ noteWrapped(steps.join('\n'), 'Next steps');
}
/** The SDK is merged and on disk; the record still names the last snapshot, which the next run merges against again. */
diff --git a/src/prompts/sdk/publish.ts b/src/prompts/sdk/publish.ts
index 94988baf1..629f6ae01 100644
--- a/src/prompts/sdk/publish.ts
+++ b/src/prompts/sdk/publish.ts
@@ -28,6 +28,11 @@ export class SdkPublishPrompts {
log.error(`The SDK in ${f.path(sdkDirectory)} could not be zipped for upload: ${problem.reason}`);
}
+ /** The generation left conflicts for the developer, so nothing is uploaded until they are gone. */
+ public conflictsNotPublished() {
+ log.warn('Nothing was published, since the SDK still has conflicts. Rerun once they are resolved and staged.');
+ }
+
public dryRunNotice(publishingSummary: string): void {
log.info(
`You can publish this SDK by removing the --dry-run flag. It will be published for the following:` +
diff --git a/src/types/common/value-map.ts b/src/types/common/value-map.ts
index 12b53c8c1..a545f6d82 100644
--- a/src/types/common/value-map.ts
+++ b/src/types/common/value-map.ts
@@ -35,6 +35,9 @@ export class ValueMap {
}
}
+/** A value map to read, for an input handed over whole. */
+export type ReadonlyValueMap = Omit, 'set'>;
+
/** A set of value objects, by what they spell. */
export class ValueSet {
private readonly members = new ValueMap();
diff --git a/src/types/git-context.ts b/src/types/git-context.ts
index 3063e54e4..881f61720 100644
--- a/src/types/git-context.ts
+++ b/src/types/git-context.ts
@@ -10,6 +10,7 @@ import { FilePath } from './file/filePath.js';
import { RelativeFilePath } from './file/relativeFilePath.js';
import { CommitId } from './git/commit-id.js';
import { GitVersion } from './git/git-version.js';
+import { SIDE_STAGE } from './git/merge-tree.js';
import { TreeEntry } from './git/tree-entry.js';
import { TreeId } from './git/tree-id.js';
import { Language, Stability } from './sdk/generate.js';
@@ -111,7 +112,6 @@ export class GitContext {
if (disk.isErr()) {
return err(disk.error);
}
- // Yours is git's "ours" and the generation its "theirs", which is what the plan reads the stages by.
const yours = await this.gitService.commitTree(this.directory, disk.value, base, 'apimatic: your changes');
if (yours.isErr()) {
return err(yours.error);
@@ -124,6 +124,14 @@ export class GitContext {
if (merged.isErr()) {
return err(merged.error);
}
+ const generatorStages = merged.value.stages.filter((entry) => entry.stage === SIDE_STAGE.generated);
+ const generatedVersions = await this.gitService.readBlobs(
+ this.directory,
+ generatorStages.map((entry) => entry.oid)
+ );
+ if (generatedVersions.isErr()) {
+ return err(generatedVersions.error);
+ }
const yourChanges = await this.gitService.diffTreesPairingRenames(this.directory, baseTree.value, disk.value);
if (yourChanges.isErr()) {
return err(yourChanges.error);
@@ -156,11 +164,22 @@ export class GitContext {
generated: generatedChanges.value,
writes: changed.map((change, index) => ({ path: change.path, content: contents.value[index] })),
deletes: applied.value.flatMap((change) => (change.status === 'D' ? [change.path] : [])),
- basePaths
+ basePaths,
+ generatedVersions: new ValueMap(
+ generatorStages.map((entry, index): [RelativeFilePath, Uint8Array] => [
+ entry.path,
+ generatedVersions.value[index]
+ ])
+ )
})
);
}
+ /** Marks the plan's conflicted paths in the repository's own index as a merge would leave them, so `git status` and a merge tool see them. */
+ public async stageConflicts(plan: MergePlan): Promise> {
+ return this.gitService.stageConflicts(this.directory, plan.stages);
+ }
+
/**
* Keeps the directory as it is, byte for byte, under the previous ref, replacing what the last run
* kept, so a write can be undone; every path `base` holds goes in, ignore rules or not.
diff --git a/src/types/git/merge-tree.ts b/src/types/git/merge-tree.ts
index 678f9e431..5af1406f3 100644
--- a/src/types/git/merge-tree.ts
+++ b/src/types/git/merge-tree.ts
@@ -11,6 +11,9 @@ export type Change =
/** The stage each side of a conflicted path is kept at, as git numbers them. */
export const STAGE = { base: 1, ours: 2, theirs: 3 } as const;
+/** The CLI merges with your tree as git's "ours" and the generation as its "theirs". */
+export const SIDE_STAGE = { yours: STAGE.ours, generated: STAGE.theirs } as const;
+
/** One conflicted path's version on one side. */
export type StageEntry = { mode: string; oid: BlobId; stage: 1 | 2 | 3; path: RelativeFilePath };
diff --git a/src/types/sdk/conflict-markers.ts b/src/types/sdk/conflict-markers.ts
new file mode 100644
index 000000000..b138bd145
--- /dev/null
+++ b/src/types/sdk/conflict-markers.ts
@@ -0,0 +1,93 @@
+import { isObjectId } from '../git/object-id.js';
+import { asBytes, asText } from './file-content.js';
+
+/** Whose lines win where you and the generator changed the same ones. */
+export type Side = 'yours' | 'generated';
+
+const OPEN = '<<<<<<< ';
+const MIDDLE = '=======';
+const CLOSE = '>>>>>>> ';
+const YOURS_LABEL = 'Your changes';
+const GENERATED_LABEL = 'Generated by APIMatic';
+
+/** The markers as the CLI writes them, naming the sides rather than the two commits git merged. */
+export const YOURS_MARKER = `${OPEN}${YOURS_LABEL}`;
+export const GENERATED_MARKER = `${CLOSE}${GENERATED_LABEL}`;
+
+type Tag = 'plain' | 'open' | 'middle' | 'close' | Side;
+
+/** One line of the file, which keeps its own trailing CR, and the part of a marker block it is. */
+type Line = { text: string; tag: Tag };
+
+// Git labels a side with the id of the commit it merged, followed by `:path` where the file was renamed.
+const isLabel = (label: string, own: string): boolean => label === own || isObjectId(label.replace(/:.*$/, ''));
+
+const bare = (text: string): string => (text.endsWith('\r') ? text.slice(0, -1) : text);
+
+/** Each line tagged by the part of a block it is in; none for a file without a block, or whose markers are not well formed. */
+function tagged(texts: readonly string[]): Line[] | undefined {
+ const lines: Line[] = [];
+ let inside: 'plain' | Side = 'plain';
+ let opened = false;
+ for (const text of texts) {
+ const line = bare(text);
+ if (line.startsWith(OPEN) && isLabel(line.slice(OPEN.length), YOURS_LABEL)) {
+ if (inside !== 'plain') {
+ return undefined;
+ }
+ lines.push({ text, tag: 'open' });
+ inside = 'yours';
+ opened = true;
+ } else if (line === MIDDLE && inside === 'yours') {
+ lines.push({ text, tag: 'middle' });
+ inside = 'generated';
+ } else if (line.startsWith(CLOSE) && isLabel(line.slice(CLOSE.length), GENERATED_LABEL)) {
+ if (inside !== 'generated') {
+ return undefined;
+ }
+ lines.push({ text, tag: 'close' });
+ inside = 'plain';
+ } else {
+ lines.push({ text, tag: inside });
+ }
+ }
+ return opened && inside === 'plain' ? lines : undefined;
+}
+
+const CLI_MARKERS: Readonly> = { open: YOURS_MARKER, close: GENERATED_MARKER };
+
+const relabel = (line: Line): string =>
+ line.tag === 'open' || line.tag === 'close'
+ ? `${CLI_MARKERS[line.tag]}${line.text.endsWith('\r') ? '\r' : ''}`
+ : line.text;
+
+/**
+ * A file as git merged it, read line by line so its marker blocks are known: a block holds your lines,
+ * then the generator's. A file without a block, or whose markers are not well formed, is left as it is.
+ */
+export class MergedText {
+ /** The lines by block part, or none for a file without a well-formed block, which every query answers with the very bytes given. */
+ private constructor(private readonly bytes: Uint8Array, private readonly lines: readonly Line[]) {}
+
+ public static parse(content: Uint8Array): MergedText {
+ return new MergedText(content, tagged(asText(content).split('\n')) ?? []);
+ }
+
+ public hasMarkers(): boolean {
+ return this.lines.length > 0;
+ }
+
+ /** The file with the CLI's labels on its markers; the very bytes given when there are none. */
+ public relabelled(): Uint8Array {
+ return this.hasMarkers() ? asBytes(this.lines.map(relabel).join('\n')) : this.bytes;
+ }
+
+ /** The file with each block settled for `side`, every line outside the blocks kept; the very bytes given when there are none. */
+ public settledFor(side: Side): Uint8Array {
+ if (!this.hasMarkers()) {
+ return this.bytes;
+ }
+ const kept = this.lines.filter((line) => line.tag === 'plain' || line.tag === side);
+ return asBytes(kept.map((line) => line.text).join('\n'));
+ }
+}
diff --git a/src/types/sdk/conflict.ts b/src/types/sdk/conflict.ts
new file mode 100644
index 000000000..833d0cb99
--- /dev/null
+++ b/src/types/sdk/conflict.ts
@@ -0,0 +1,166 @@
+import { RelativeFilePath } from '../file/relativeFilePath.js';
+import { StageEntry } from '../git/merge-tree.js';
+import { MergedText, Side } from './conflict-markers.js';
+
+export type ConflictKind =
+ /** You and the generator changed the same lines. */
+ | 'bothChanged'
+ /** You and the generator both added the path with different content. */
+ | 'bothAdded'
+ /** You deleted the file and the generator changed it. */
+ | 'deletedByYou'
+ /** You edited the file and the generator no longer produces it. */
+ | 'noLongerGenerated'
+ /** You and the generator both changed a binary file. */
+ | 'binary'
+ /** A shape git reports that fits none of the above, such as both sides renaming one file differently. */
+ | 'other';
+
+/** A file to write, with its content as the merged tree holds it. */
+export type FileWrite = { path: RelativeFilePath; content: Uint8Array };
+
+/** Every path the run touched or left alone, by what happened to it; conflicted paths are listed under `conflicts` instead. */
+export type MergeFiles = {
+ /** Files the generator produced for the first time. */
+ added: readonly RelativeFilePath[];
+ /** Unedited files replaced by their new version, or moved to the generator's new name. */
+ updated: readonly RelativeFilePath[];
+ /** Edited files the generator also changed, merged without conflict. */
+ merged: readonly RelativeFilePath[];
+ /** Unedited files the generator stopped producing. */
+ removed: readonly RelativeFilePath[];
+ /** Files you edited or added that the generator left alone. */
+ kept: readonly RelativeFilePath[];
+};
+
+type CountedAs = 'merged' | 'updated' | 'kept';
+
+/** How one conflict lands once a side is chosen: what the run writes for it, and what the summary says about it. */
+export class Settlement {
+ private constructor(
+ private readonly written: readonly FileWrite[],
+ private readonly counted: { as: CountedAs; path: RelativeFilePath } | undefined
+ ) {}
+
+ /** The chosen side's lines in each block, counted among the merged files. */
+ public static merged(write: FileWrite): Settlement {
+ return new Settlement([write], { as: 'merged', path: write.path });
+ }
+
+ /** The generator's whole version, counted among the updated files. */
+ public static updated(write: FileWrite): Settlement {
+ return new Settlement([write], { as: 'updated', path: write.path });
+ }
+
+ /** Your file stays as it is, named among the kept ones. */
+ public static kept(path: RelativeFilePath): Settlement {
+ return new Settlement([], { as: 'kept', path });
+ }
+
+ /** Nothing on disk changes and the summary says nothing: a deletion of yours stands. */
+ public static untouched(): Settlement {
+ return new Settlement([], undefined);
+ }
+
+ /** Git's placement stands, with nothing said: a conflict of another shape. */
+ public static asPlaced(writes: readonly FileWrite[]): Settlement {
+ return new Settlement(writes, undefined);
+ }
+
+ public writes(): readonly FileWrite[] {
+ return this.written;
+ }
+
+ /** `files` with this settlement's path added under what the summary calls it. */
+ public countedIn(files: MergeFiles): MergeFiles {
+ return this.counted === undefined
+ ? files
+ : { ...files, [this.counted.as]: [...files[this.counted.as], this.counted.path] };
+ }
+}
+
+/** A path git could not merge: what it is, what a hand resolution starts from, and how a chosen side settles it. */
+export interface Conflict {
+ readonly path: RelativeFilePath;
+ readonly kind: ConflictKind;
+ /** The versions git keeps for the path, for the index to hold as a merge would leave it. */
+ readonly stages: readonly StageEntry[];
+ /** Whether the file is written with conflict markers in it, for a merge tool to find. */
+ leavesMarkers(): boolean;
+ /** What the run writes for the path when the developer resolves by hand: the file as git placed it, or nothing. */
+ asPlaced(): readonly FileWrite[];
+ settledFor(side: Side): Settlement;
+}
+
+/** You and the generator changed the same lines, or both added the path: git marked each block, and a side settles it. */
+export class MarkedConflict implements Conflict {
+ constructor(
+ public readonly path: RelativeFilePath,
+ public readonly kind: 'bothChanged' | 'bothAdded',
+ public readonly stages: readonly StageEntry[],
+ private readonly text: MergedText
+ ) {}
+
+ public leavesMarkers(): boolean {
+ return true;
+ }
+
+ public asPlaced(): readonly FileWrite[] {
+ return [{ path: this.path, content: this.text.relabelled() }];
+ }
+
+ public settledFor(side: Side): Settlement {
+ return Settlement.merged({ path: this.path, content: this.text.settledFor(side) });
+ }
+}
+
+/**
+ * A file git could not merge line by line, since a side deleted it or it is binary, or one it marked but did
+ * not need to write: a side takes it whole, as the settlement made for that side says.
+ */
+export class WholeConflict implements Conflict {
+ constructor(
+ public readonly path: RelativeFilePath,
+ public readonly kind: Exclude,
+ public readonly stages: readonly StageEntry[],
+ private readonly placed: readonly FileWrite[],
+ private readonly generated: Settlement,
+ private readonly yours: Settlement
+ ) {}
+
+ public leavesMarkers(): boolean {
+ return false;
+ }
+
+ public asPlaced(): readonly FileWrite[] {
+ return this.placed;
+ }
+
+ public settledFor(side: Side): Settlement {
+ return side === 'yours' ? this.yours : this.generated;
+ }
+}
+
+/** A shape git reports that fits no other, such as both sides renaming one file differently. */
+export class PlacedConflict implements Conflict {
+ public readonly kind = 'other';
+
+ constructor(
+ public readonly path: RelativeFilePath,
+ public readonly stages: readonly StageEntry[],
+ private readonly placed: readonly FileWrite[]
+ ) {}
+
+ public leavesMarkers(): boolean {
+ return false;
+ }
+
+ public asPlaced(): readonly FileWrite[] {
+ return this.placed;
+ }
+
+ // Either side lands as git placed it.
+ public settledFor(): Settlement {
+ return Settlement.asPlaced(this.placed);
+ }
+}
diff --git a/src/types/sdk/file-content.ts b/src/types/sdk/file-content.ts
index 2e49a8708..9c7e3012d 100644
--- a/src/types/sdk/file-content.ts
+++ b/src/types/sdk/file-content.ts
@@ -6,9 +6,9 @@ export const isBinary = (content: Uint8Array): boolean => content.subarray(0, 80
/** A file whose line endings are never changed: a binary one, or one that already carries a CR. */
export const keepsLineEndings = (content: Uint8Array): boolean => content.includes(0x0d) || isBinary(content);
-// One byte is one character and back, whatever the file's encoding, so only the line endings can change.
-const asText = (content: Uint8Array): string => Buffer.from(content).toString('latin1');
-const asBytes = (text: string): Uint8Array => new Uint8Array(Buffer.from(text, 'latin1'));
+// One byte is one character and back, whatever the file's encoding, so only the lines touched can change.
+export const asText = (content: Uint8Array): string => Buffer.from(content).toString('latin1');
+export const asBytes = (text: string): Uint8Array => new Uint8Array(Buffer.from(text, 'latin1'));
/** `content` written the way `sample` ends its lines: CRLF where the sample uses it, else as it is. */
export function lineEndingFor(content: Uint8Array, sample: Uint8Array | undefined): Uint8Array {
diff --git a/src/types/sdk/merge-plan.ts b/src/types/sdk/merge-plan.ts
index a55d331ce..7a15bb9d9 100644
--- a/src/types/sdk/merge-plan.ts
+++ b/src/types/sdk/merge-plan.ts
@@ -1,9 +1,17 @@
-import { ValueMap, ValueSet } from '../common/value-map.js';
+import { ReadonlyValueMap, ValueMap, ValueSet } from '../common/value-map.js';
import { RelativeFilePath } from '../file/relativeFilePath.js';
-import { Change, MergeMessageKind, MergeTreeOutput, STAGE } from '../git/merge-tree.js';
-
-/** The merge runs with your tree as git's "ours" and the generation as its "theirs". */
-const SIDE_STAGE = { yours: STAGE.ours, generated: STAGE.theirs } as const;
+import { Change, MergeMessage, MergeMessageKind, MergeTreeOutput, SIDE_STAGE, StageEntry } from '../git/merge-tree.js';
+import { MergedText, Side } from './conflict-markers.js';
+import {
+ Conflict,
+ ConflictKind,
+ FileWrite,
+ MarkedConflict,
+ MergeFiles,
+ PlacedConflict,
+ Settlement,
+ WholeConflict
+} from './conflict.js';
export type MergeInputs = {
merge: MergeTreeOutput;
@@ -16,40 +24,11 @@ export type MergeInputs = {
deletes: readonly RelativeFilePath[];
/** Every path the base holds. */
basePaths: readonly RelativeFilePath[];
+ /** The generator's version of each conflicted path it still produces, as git kept it. */
+ generatedVersions: ReadonlyValueMap;
};
-export type ConflictKind =
- /** You and the generator changed the same lines. */
- | 'bothChanged'
- /** You and the generator both added the path with different content. */
- | 'bothAdded'
- /** You deleted the file and the generator changed it. */
- | 'deletedByYou'
- /** You edited the file and the generator no longer produces it. */
- | 'noLongerGenerated'
- /** You and the generator both changed a binary file. */
- | 'binary'
- /** A shape git reports that fits none of the above, such as both sides renaming one file differently. */
- | 'other';
-
-/** A file to write, with its content as the merged tree holds it. */
-export type FileWrite = { path: RelativeFilePath; content: Uint8Array };
-
-export type Conflict = { path: RelativeFilePath; kind: ConflictKind };
-
-/** Every path the run touched or left alone, by what happened to it; conflicted paths are listed under `conflicts` instead. */
-export type MergeFiles = {
- /** Files the generator produced for the first time. */
- added: readonly RelativeFilePath[];
- /** Unedited files replaced by their new version, or moved to the generator's new name. */
- updated: readonly RelativeFilePath[];
- /** Edited files the generator also changed, merged without conflict. */
- merged: readonly RelativeFilePath[];
- /** Unedited files the generator stopped producing. */
- removed: readonly RelativeFilePath[];
- /** Files you edited or added that the generator left alone. */
- kept: readonly RelativeFilePath[];
-};
+type ConflictFact = { path: RelativeFilePath; kind: ConflictKind };
/** The stronger of two accounts of one path, when git reports it more than once. */
const RANK: Record = {
@@ -61,8 +40,12 @@ const RANK: Record = {
other: 1
};
+/** Whether `side` still has the path, by the stage git kept for it. */
+const has = (entries: readonly StageEntry[], side: Side): boolean =>
+ entries.some((entry) => entry.stage === SIDE_STAGE[side]);
+
/** What a conflict git reported means here; a deletion is yours or the generator's by which side still has the file. */
-function kindOf(reported: Exclude, stages: ReadonlySet): ConflictKind {
+function kindOf(reported: Exclude, entries: readonly StageEntry[]): ConflictKind {
switch (reported) {
case 'binary':
return 'binary';
@@ -70,7 +53,7 @@ function kindOf(reported: Exclude, stages: Re
return 'bothAdded';
case 'modifyDelete':
case 'renameDelete':
- return stages.has(SIDE_STAGE.yours) ? 'noLongerGenerated' : 'deletedByYou';
+ return has(entries, 'yours') ? 'noLongerGenerated' : 'deletedByYou';
case 'contents':
return 'bothChanged';
case 'otherConflict':
@@ -78,18 +61,26 @@ function kindOf(reported: Exclude, stages: Re
}
}
-function conflictsOf(merge: MergeTreeOutput): Conflict[] {
- const stagesByPath = new ValueMap>();
- for (const entry of merge.stages) {
- stagesByPath.set(entry.path, (stagesByPath.get(entry.path) ?? new Set()).add(entry.stage));
+/** The stages git kept for each conflicted path. */
+function stagesByPath(entries: readonly StageEntry[]): ValueMap {
+ const stages = new ValueMap();
+ for (const entry of entries) {
+ stages.set(entry.path, [...(stages.get(entry.path) ?? []), entry]);
}
- const byPath = new ValueMap();
- for (const { paths, kind: reported } of merge.messages) {
+ return stages;
+}
+
+function conflictsOf(
+ messages: readonly MergeMessage[],
+ stages: ValueMap
+): ConflictFact[] {
+ const byPath = new ValueMap();
+ for (const { paths, kind: reported } of messages) {
if (reported === 'informational') {
continue;
}
for (const path of paths) {
- const kind = kindOf(reported, stagesByPath.get(path) ?? new Set());
+ const kind = kindOf(reported, stages.get(path) ?? []);
const known = byPath.get(path);
if (known === undefined || RANK[kind] > RANK[known.kind]) {
byPath.set(path, { path, kind });
@@ -99,6 +90,28 @@ function conflictsOf(merge: MergeTreeOutput): Conflict[] {
return byPath.values();
}
+/** The conflict as the object that knows how it lands: marked blocks, a whole file a side takes, or git's placement. */
+function conflictOf(
+ fact: ConflictFact,
+ placed: FileWrite | undefined,
+ entries: readonly StageEntry[],
+ generatedVersion: Uint8Array | undefined
+): Conflict {
+ const asPlaced = placed === undefined ? [] : [placed];
+ if (fact.kind === 'other') {
+ return new PlacedConflict(fact.path, entries, asPlaced);
+ }
+ if (placed !== undefined && (fact.kind === 'bothChanged' || fact.kind === 'bothAdded')) {
+ return new MarkedConflict(fact.path, fact.kind, entries, MergedText.parse(placed.content));
+ }
+ // A file git marked but did not need to write, since the disk already holds the merge, lands whole like one a side deleted.
+ const yours = has(entries, 'yours') ? Settlement.kept(fact.path) : Settlement.untouched();
+ // A generation that no longer produces the file leaves yours in place for either side, so an edit is never thrown away by a choice that keeps the rest.
+ const generated =
+ generatedVersion === undefined ? yours : Settlement.updated({ path: fact.path, content: generatedVersion });
+ return new WholeConflict(fact.path, fact.kind, entries, asPlaced, generated, yours);
+}
+
/** Each change under every path it names, so a rename is found by either name. */
const changedPaths = (changes: readonly Change[]): ValueMap =>
new ValueMap(
@@ -112,10 +125,10 @@ const changedPaths = (changes: readonly Change[]): ValueMap,
+ inConflict: ValueSet,
yours: ValueMap,
generated: ValueMap
): MergeFiles {
@@ -128,12 +141,12 @@ function filesOf(
kept: []
};
for (const path of inputs.deletes) {
- if (!conflicted.has(path)) {
+ if (!inConflict.has(path)) {
files.removed.push(path);
}
}
for (const { path } of inputs.writes) {
- if (conflicted.has(path)) {
+ if (inConflict.has(path)) {
continue;
}
if (!base.has(path) && generated.get(path)?.status === 'A') {
@@ -145,7 +158,7 @@ function filesOf(
}
}
for (const [path, change] of yours) {
- if ((change.status === 'M' || change.status === 'A') && !generated.has(path) && !conflicted.has(path)) {
+ if ((change.status === 'M' || change.status === 'A') && !generated.has(path) && !inConflict.has(path)) {
files.kept.push(change.path);
}
}
@@ -154,28 +167,60 @@ function filesOf(
/** What a run writes and removes below the SDK directory; a path the plan does not mention is left as it is. */
export class MergePlan {
+ /** Every file the run writes when the conflicts are left for you: the merged ones, and each conflicted one as git placed it. */
+ public readonly writes: readonly FileWrite[];
+
constructor(
- public readonly writes: readonly FileWrite[],
+ private readonly uncontested: readonly FileWrite[],
public readonly deletes: readonly RelativeFilePath[],
public readonly conflicts: readonly Conflict[],
public readonly files: MergeFiles
- ) {}
+ ) {
+ this.writes = [...uncontested, ...conflicts.flatMap((conflict) => conflict.asPlaced())];
+ }
+
+ /** Every conflicted path's versions as git keeps them, for the index to hold as a merge would leave it. */
+ public get stages(): readonly StageEntry[] {
+ return this.conflicts.flatMap((conflict) => conflict.stages);
+ }
/** What git's merge means for the SDK directory: writes, deletions, conflicts, and the summary. */
public static from(inputs: MergeInputs): MergePlan {
- const conflicts = conflictsOf(inputs.merge);
- const conflicted = new ValueSet(conflicts.map((conflict) => conflict.path));
- const yours = changedPaths(inputs.yours);
- const generated = changedPaths(inputs.generated);
- return new MergePlan(inputs.writes, inputs.deletes, conflicts, filesOf(inputs, conflicted, yours, generated));
+ const stages = stagesByPath(inputs.merge.stages);
+ const placed = new ValueMap(inputs.writes.map((write): [RelativeFilePath, FileWrite] => [write.path, write]));
+ const facts = conflictsOf(inputs.merge.messages, stages);
+ const inConflict = new ValueSet(facts.map((fact) => fact.path));
+ // A path git names that the merge leaves nowhere, neither staged nor written, such as the old name of a file it moved or you deleted, is no file to settle, and the summary leaves it out as well.
+ const conflicts = facts
+ .filter(({ path }) => stages.has(path) || placed.has(path))
+ .map((fact) =>
+ conflictOf(fact, placed.get(fact.path), stages.get(fact.path) ?? [], inputs.generatedVersions.get(fact.path))
+ );
+ return new MergePlan(
+ inputs.writes.filter((write) => !inConflict.has(write.path)),
+ inputs.deletes,
+ conflicts,
+ filesOf(inputs, inConflict, changedPaths(inputs.yours), changedPaths(inputs.generated))
+ );
+ }
+
+ /** Every conflict settled for `side`, as the question's answer is, with the summary counting each as it lands. */
+ public taking(side: Side): MergePlan {
+ const settled = this.conflicts.map((conflict) => conflict.settledFor(side));
+ return new MergePlan(
+ [...this.uncontested, ...settled.flatMap((settlement) => settlement.writes())],
+ this.deletes,
+ [],
+ settled.reduce((files, settlement) => settlement.countedIn(files), this.files)
+ );
}
public hasConflicts(): boolean {
return this.conflicts.length > 0;
}
- /** Whether the run would write nothing and remove nothing. */
+ /** Whether the run would write nothing, remove nothing, and leave nothing in conflict. */
public isEmpty(): boolean {
- return this.writes.length === 0 && this.deletes.length === 0;
+ return this.writes.length === 0 && this.deletes.length === 0 && this.conflicts.length === 0;
}
}
diff --git a/test/actions/action-result.test.ts b/test/actions/action-result.test.ts
index 87e025fc9..b703fb304 100644
--- a/test/actions/action-result.test.ts
+++ b/test/actions/action-result.test.ts
@@ -30,4 +30,32 @@ describe('ActionResult', () => {
expect(result.isFailed()).to.be.true;
expect(result.getError()).to.be.undefined;
});
+
+ const kindOf = (result: ActionResult) =>
+ result.match({
+ success: () => 'success',
+ failed: () => 'failed',
+ cancelled: () => 'cancelled',
+ actionRequired: () => 'actionRequired'
+ });
+
+ it('is neither a failure nor a cancellation when action is required, yet exits 1 as git merge does on conflicts', () => {
+ const result = ActionResult.actionRequired();
+
+ expect(result.isActionRequired()).to.be.true;
+ expect(result.isFailed()).to.be.false;
+ expect(result.isCancelled()).to.be.false;
+ expect(result.isSuccess()).to.be.false;
+ expect(result.getMessage()).to.equal('Action required');
+ expect(result.getExitCode()).to.equal(1);
+ expect(kindOf(result)).to.equal('actionRequired');
+ expect(kindOf(result.discardValue())).to.equal('actionRequired');
+ });
+
+ it('keeps 130 for a cancellation and 0 for a success', () => {
+ expect(ActionResult.cancelled().getExitCode()).to.equal(130);
+ expect(ActionResult.success().getExitCode()).to.equal(0);
+ expect(kindOf(ActionResult.cancelled())).to.equal('cancelled');
+ expect(kindOf(ActionResult.success())).to.equal('success');
+ });
});
diff --git a/test/actions/sdk/generate-merge.test.ts b/test/actions/sdk/generate-merge.test.ts
index 13a08af8a..ce0abb3a5 100644
--- a/test/actions/sdk/generate-merge.test.ts
+++ b/test/actions/sdk/generate-merge.test.ts
@@ -2,6 +2,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { expect } from 'chai';
+import { execa } from 'execa';
import { err } from 'neverthrow';
import sinon from 'sinon';
import { GenerateAction } from '../../../src/actions/sdk/generate.js';
@@ -14,10 +15,12 @@ import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
import { CommitId } from '../../../src/types/git/commit-id.js';
import { GitContext } from '../../../src/types/git-context.js';
import { ProjectContext } from '../../../src/types/project-context.js';
+import { GENERATED_MARKER, YOURS_MARKER } from '../../../src/types/sdk/conflict-markers.js';
import { Language, Stability } from '../../../src/types/sdk/generate.js';
import { GenerationRecord, recordContents } from '../../../src/types/sdk/generation-record.js';
+import { kinds } from '../../helpers/merge.js';
import { Files, stubSdkGeneration, writeFiles } from '../../helpers/sdk-generation.js';
-import { describeCommit } from '../../helpers/snapshots.js';
+import { describeCommit, snapshotFiles } from '../../helpers/snapshots.js';
const COMMAND_METADATA: CommandMetadata = { commandName: 'sdk generate', shell: 'test' };
/** Where the SDK directory's own repository keeps each generation as the server returned it. */
@@ -39,6 +42,12 @@ const MODEL_V1 = [
const MODEL_V2 = MODEL_V1.replace('# Price in USD.', '# Retail price in US dollars.') + ' isbn: str\n';
const DECORATED = 'from dataclasses import dataclass\n\n\n@dataclass\n';
const MODEL_V1_EDITED = MODEL_V1.replace('# Price in USD.', '# Price in USD, VAT included.');
+/** The edited model merged with v2 as git leaves it: the comment in conflict, the new field below. */
+const MODEL_MARKED =
+ MODEL_V1.replace(
+ ' # Price in USD.\n',
+ `${YOURS_MARKER}\n # Price in USD, VAT included.\n=======\n # Retail price in US dollars.\n${GENERATED_MARKER}\n`
+ ) + ' isbn: str\n';
const V1: Files = { 'README.md': '# sdk', 'models/book.py': MODEL_V1 };
const V2: Files = { 'README.md': '# sdk v2', 'models/book.py': MODEL_V2, 'models/author.py': 'class Author: ...' };
@@ -108,7 +117,7 @@ describe('GenerateAction (sdk) — a regeneration merges', () => {
expect(read('README.md')).to.equal('# sdk v2');
expect(read('models/author.py')).to.equal('class Author: ...');
expect(read('notes.txt')).to.equal('mine');
- const files = sdkMerged().firstCall.args[1];
+ const { files } = sdkMerged().firstCall.args[1];
expect(listed(files.added)).to.deep.equal(['models/author.py']);
expect(listed(files.updated)).to.deep.equal(['README.md']);
expect(listed(files.merged)).to.deep.equal(['models/book.py']);
@@ -118,18 +127,18 @@ describe('GenerateAction (sdk) — a regeneration merges', () => {
expect((await describeCommit(sdk(), base()!)).parents).to.deep.equal([first]);
});
- it('refuses conflicts, naming them, and writes nothing', async () => {
+ it('refuses without a terminal, naming the conflicts, and writes nothing', async () => {
const first = await recorded();
write({ 'models/book.py': MODEL_V1_EDITED });
+ const conflictChoice = sinon.stub(SdkGeneratePrompts.prototype, 'conflictChoice');
const conflictsNotAsked = sinon.stub(SdkGeneratePrompts.prototype, 'conflictsNotAsked');
- expect((await execute(V2)).isFailed()).to.be.true;
+ expect((await execute(V2, { canAsk: false })).isFailed()).to.be.true;
const [directory, conflicts] = conflictsNotAsked.firstCall.args;
- expect(conflicts.map((conflict) => [conflict.path.toString(), conflict.kind])).to.deep.equal([
- ['models/book.py', 'bothChanged']
- ]);
+ expect(kinds(conflicts)).to.deep.equal([['models/book.py', 'bothChanged']]);
expect(directory.isEqual(sdk())).to.be.true;
+ expect(conflictChoice.called).to.be.false;
expect(read('models/book.py')).to.equal(MODEL_V1_EDITED);
expect(read('README.md')).to.equal('# sdk');
expect(fs.existsSync(at('models/author.py'))).to.be.false;
@@ -137,6 +146,50 @@ describe('GenerateAction (sdk) — a regeneration merges', () => {
expect(sdkMerged().called).to.be.false;
});
+ it('writes markers where you and the generator changed the same lines when you will resolve them, for a merge tool to find', async () => {
+ const first = await recorded();
+ write({ 'models/book.py': MODEL_V1_EDITED });
+ sinon.stub(SdkGeneratePrompts.prototype, 'conflictChoice').resolves('asPlaced');
+ const conflictSteps = sinon.stub(SdkGeneratePrompts.prototype, 'conflictSteps');
+
+ const result = await execute(V2);
+
+ expect(result.isActionRequired()).to.be.true;
+ const [directory, plan, staged] = conflictSteps.firstCall.args;
+ expect(directory.isEqual(sdk())).to.be.true;
+ expect(kinds(plan.conflicts)).to.deep.equal([['models/book.py', 'bothChanged']]);
+ expect(staged).to.be.true;
+ expect(sdkMerged().firstCall.args[1]).to.equal(plan);
+ expect(read('models/book.py')).to.equal(MODEL_MARKED);
+ expect(read('README.md')).to.equal('# sdk v2');
+ expect(read('models/author.py')).to.equal('class Author: ...');
+ expect(base()).to.deep.equal(await pristine());
+ expect((await describeCommit(sdk(), base()!)).parents).to.deep.equal([first]);
+ const previous = (await git.resolveRef(sdk(), PREVIOUS_REF))._unsafeUnwrap()!;
+ expect((await snapshotFiles(sdk(), previous)).get('models/book.py')).to.equal(MODEL_V1_EDITED);
+ const cwd = sdk().toString();
+ expect((await execa('git', ['status', '--porcelain'], { cwd })).stdout).to.contain('UU models/book.py');
+ const stages = (await execa('git', ['ls-files', '--stage', '--', 'models/book.py'], { cwd })).stdout;
+ expect(stages.split('\n').map((line) => line.split(/\s+/)[2])).to.deep.equal(['1', '2', '3']);
+ });
+
+ it('leaves the merge tool out of the steps when the conflicts cannot be marked in the index, with a warning', async () => {
+ await recorded();
+ write({ 'models/book.py': MODEL_V1_EDITED });
+ sinon.stub(SdkGeneratePrompts.prototype, 'conflictChoice').resolves('asPlaced');
+ sinon.stub(GitService.prototype, 'stageConflicts').resolves(err('index locked'));
+ const conflictsNotStaged = sinon.stub(SdkGeneratePrompts.prototype, 'conflictsNotStaged');
+ const conflictSteps = sinon.stub(SdkGeneratePrompts.prototype, 'conflictSteps');
+
+ const result = await execute(V2);
+
+ expect(result.isActionRequired()).to.be.true;
+ expect(conflictsNotStaged.firstCall.args).to.deep.equal(['index locked']);
+ expect(conflictSteps.firstCall.args[2]).to.be.false;
+ expect(conflictsNotStaged.calledBefore(conflictSteps)).to.be.true;
+ expect(read('models/book.py')).to.contain(YOURS_MARKER);
+ });
+
for (const [what, prepare] of [
['another stability', async () => ({ stability: Stability.BETA })],
[
@@ -264,4 +317,127 @@ describe('GenerateAction (sdk) — a regeneration merges', () => {
expect(base()).to.deep.equal(first);
expect(sdkMerged().calledOnce).to.be.true;
});
+
+ describe('the question when the merge conflicts', () => {
+ const NEXT: Files = { 'README.md': '# sdk v2', 'models/book.py': MODEL_V2 };
+
+ /** The two conflicts `conflicting` sets up, as the prompts are told of them. */
+ const CONFLICTS = [
+ ['models/book.py', 'bothChanged'],
+ ['helpers/old.py', 'noLongerGenerated']
+ ];
+
+ /** An edited model the generator also changed, an edited helper it stopped producing, and a clean change elsewhere. */
+ const conflicting = async () => {
+ const first = await recorded({ ...V1, 'helpers/old.py': 'old' });
+ write({ 'models/book.py': DECORATED + MODEL_V1_EDITED, 'helpers/old.py': 'old, edited', 'notes.txt': 'mine' });
+ return first;
+ };
+
+ it('settles the overlapping hunks for the generator on request, keeping your other edits and a file it stopped producing', async () => {
+ await conflicting();
+ sinon.stub(SdkGeneratePrompts.prototype, 'conflictChoice').resolves('generated');
+ const conflictsResolved = sinon.stub(SdkGeneratePrompts.prototype, 'conflictsResolved');
+
+ const result = await execute(NEXT);
+
+ expect(result.isSuccess()).to.be.true;
+ expect(read('models/book.py')).to.equal(DECORATED + MODEL_V2);
+ expect(read('helpers/old.py')).to.equal('old, edited');
+ expect(read('README.md')).to.equal('# sdk v2');
+ expect(read('notes.txt')).to.equal('mine');
+ const [directory, side, conflicts] = conflictsResolved.firstCall.args;
+ expect(directory.isEqual(sdk())).to.be.true;
+ expect(side).to.equal('generated');
+ expect(kinds(conflicts)).to.have.deep.members(CONFLICTS);
+ expect(conflictsResolved.calledBefore(sdkMerged())).to.be.true;
+ const { files } = sdkMerged().firstCall.args[1];
+ expect(listed(files.merged)).to.deep.equal(['models/book.py']);
+ expect(listed(files.kept)).to.have.members(['helpers/old.py', 'notes.txt']);
+ const previous = (await git.resolveRef(sdk(), PREVIOUS_REF))._unsafeUnwrap()!;
+ expect((await snapshotFiles(sdk(), previous)).get('models/book.py')).to.equal(DECORATED + MODEL_V1_EDITED);
+ });
+
+ it('settles the overlapping hunks for you on request, with the generator’s other changes in the same file', async () => {
+ await conflicting();
+ sinon.stub(SdkGeneratePrompts.prototype, 'conflictChoice').resolves('yours');
+ const conflictsResolved = sinon.stub(SdkGeneratePrompts.prototype, 'conflictsResolved');
+
+ const result = await execute(NEXT);
+
+ expect(result.isSuccess()).to.be.true;
+ expect(read('models/book.py')).to.equal(DECORATED + MODEL_V1_EDITED + ' isbn: str\n');
+ expect(read('helpers/old.py')).to.equal('old, edited');
+ expect(read('README.md')).to.equal('# sdk v2');
+ expect(read('notes.txt')).to.equal('mine');
+ expect(conflictsResolved.firstCall.args[1]).to.equal('yours');
+ expect(kinds(conflictsResolved.firstCall.args[2])).to.have.deep.members(CONFLICTS);
+ });
+
+ it('writes nothing at all when the developer cancels at the question', async () => {
+ const first = await conflicting();
+ sinon.stub(SdkGeneratePrompts.prototype, 'conflictChoice').resolves('cancel');
+ const runCancelled = sinon.stub(SdkGeneratePrompts.prototype, 'runCancelled');
+
+ const result = await execute(NEXT);
+
+ expect(result.isCancelled()).to.be.true;
+ expect(read('models/book.py')).to.equal(DECORATED + MODEL_V1_EDITED);
+ expect(read('README.md')).to.equal('# sdk');
+ expect(base()).to.deep.equal(first);
+ expect((await git.resolveRef(sdk(), PREVIOUS_REF))._unsafeUnwrap()).to.be.undefined;
+ expect(runCancelled.firstCall.args[0].isEqual(sdk())).to.be.true;
+ expect(sdkMerged().called).to.be.false;
+ });
+
+ it('leaves markers when the developer will resolve, and ends as Action required with the steps', async () => {
+ await conflicting();
+ sinon.stub(SdkGeneratePrompts.prototype, 'conflictChoice').resolves('asPlaced');
+ const conflictSteps = sinon.stub(SdkGeneratePrompts.prototype, 'conflictSteps');
+
+ const result = await execute(NEXT);
+
+ expect(result.isActionRequired()).to.be.true;
+ expect(read('models/book.py')).to.equal(DECORATED + MODEL_MARKED);
+ expect(read('helpers/old.py')).to.equal('old, edited');
+ expect(read('notes.txt')).to.equal('mine');
+ expect(conflictSteps.calledOnce).to.be.true;
+ expect(sdkMerged().calledBefore(conflictSteps)).to.be.true;
+ });
+
+ for (const [where, canAsk] of [
+ ['in a terminal', true],
+ ['without one', false]
+ ] as const) {
+ it(`does not ask with --force ${where}, and settles the overlapping hunks for the generator`, async () => {
+ await conflicting();
+ const conflictChoice = sinon.stub(SdkGeneratePrompts.prototype, 'conflictChoice');
+ const conflictsNotAsked = sinon.stub(SdkGeneratePrompts.prototype, 'conflictsNotAsked');
+ const conflictsResolved = sinon.stub(SdkGeneratePrompts.prototype, 'conflictsResolved');
+
+ const result = await execute(NEXT, { force: true, canAsk });
+
+ expect(result.isSuccess()).to.be.true;
+ expect(conflictChoice.called).to.be.false;
+ expect(conflictsNotAsked.called).to.be.false;
+ expect(read('models/book.py')).to.equal(DECORATED + MODEL_V2);
+ expect(read('helpers/old.py')).to.equal('old, edited');
+ expect(read('notes.txt')).to.equal('mine');
+ expect(conflictsResolved.firstCall.args[1]).to.equal('generated');
+ expect(kinds(conflictsResolved.firstCall.args[2])).to.have.deep.members(CONFLICTS);
+ });
+ }
+
+ it('asks nothing when the merge is clean', async () => {
+ await recorded();
+ write({ 'notes.txt': 'mine' });
+ const conflictChoice = sinon.stub(SdkGeneratePrompts.prototype, 'conflictChoice');
+ const conflictsResolved = sinon.stub(SdkGeneratePrompts.prototype, 'conflictsResolved');
+
+ expect((await execute(NEXT)).isSuccess()).to.be.true;
+
+ expect(conflictChoice.called).to.be.false;
+ expect(conflictsResolved.called).to.be.false;
+ });
+ });
});
diff --git a/test/actions/sdk/publish.test.ts b/test/actions/sdk/publish.test.ts
index 2f9b7e97e..fcf8da086 100644
--- a/test/actions/sdk/publish.test.ts
+++ b/test/actions/sdk/publish.test.ts
@@ -1,37 +1,37 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
-import { Readable } from 'node:stream';
import { expect } from 'chai';
import { err, ok } from 'neverthrow';
import sinon from 'sinon';
import { SdkPublishAction } from '../../../src/actions/sdk/publish.js';
import { SdkPublishNonInteractiveAction } from '../../../src/actions/sdk/publish/non-interactive.js';
+import { GitService } from '../../../src/infrastructure/git-service.js';
import { LauncherService } from '../../../src/infrastructure/launcher-service.js';
import { PublishingApiService } from '../../../src/infrastructure/services/publishing-api-service.js';
-import { SdkGenerationService } from '../../../src/infrastructure/services/sdk-generation-service.js';
-import { ZipService } from '../../../src/infrastructure/zip-service.js';
import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js';
import { SdkPublishPrompts } from '../../../src/prompts/sdk/publish.js';
import { SdkPublishNonInteractivePrompts } from '../../../src/prompts/sdk/publish/non-interactive.js';
import { CommandMetadata } from '../../../src/types/common/command-metadata.js';
import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
-import { FileName } from '../../../src/types/file/fileName.js';
-import { FilePath } from '../../../src/types/file/filePath.js';
import { FileProblem } from '../../../src/types/file/file-problem.js';
+import { GitContext } from '../../../src/types/git-context.js';
import { ProjectContext } from '../../../src/types/project-context.js';
import { PublishingProfileItem, PublishType } from '../../../src/types/publish-api/publishing-profile-item.js';
import { ProfileId } from '../../../src/types/publish/profile-id.js';
import { PublishingProfile } from '../../../src/types/publish/publishing-profile.js';
import { SemVersion } from '../../../src/types/publish/version.js';
+import { YOURS_MARKER } from '../../../src/types/sdk/conflict-markers.js';
import { Language, Stability } from '../../../src/types/sdk/generate.js';
import { StabilityChoice } from '../../../src/types/sdk/stability-choice.js';
import { TempContext } from '../../../src/types/temp-context.js';
+import { Files, stubSdkGeneration, writeFiles } from '../../helpers/sdk-generation.js';
const COMMAND_METADATA: CommandMetadata = { commandName: 'sdk publish', shell: 'test' };
describe('SdkPublishAction', () => {
let root: string;
+ let generates: (files: Files) => Promise;
let onPublishSdkError: sinon.SinonStub;
const withoutPackageSettings = {
@@ -40,15 +40,15 @@ describe('SdkPublishAction', () => {
const sdk = () => new DirectoryPath(path.join(root, 'sdk', 'python'));
- const execute = (dryRun = false) =>
+ const execute = (dryRun = false, { force = true, canAsk = false } = {}) =>
new SdkPublishAction(new DirectoryPath(root), COMMAND_METADATA).execute(
ProjectContext.in(new DirectoryPath(root)),
sdk(),
Language.PYTHON,
Stability.STABLE,
[PublishType.PackagePublishing],
- true,
- false,
+ force,
+ canAsk,
ProfileId.tryCreate('profile')._unsafeUnwrap(),
SemVersion.tryCreate('1.0.0')._unsafeUnwrap(),
withoutPackageSettings,
@@ -59,18 +59,8 @@ describe('SdkPublishAction', () => {
beforeEach(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-publish-'));
- fs.mkdirSync(path.join(root, 'src', 'spec'), { recursive: true });
- fs.writeFileSync(path.join(root, 'src', 'spec', 'openapi.json'), '{}');
-
- const generated = path.join(root, 'generated');
- fs.mkdirSync(generated);
- fs.writeFileSync(path.join(generated, 'README.md'), '# sdk');
- const archive = new FilePath(new DirectoryPath(root), new FileName('sdk.zip'));
- (await new ZipService().archive(new DirectoryPath(generated), archive))._unsafeUnwrap();
- const sdkArchive = fs.readFileSync(archive.toString());
-
- sinon.stub(SdkGeneratePrompts.prototype, 'generateSdk').callsFake((fn) => fn);
- sinon.stub(SdkGenerationService.prototype, 'generateSdk').callsFake(async () => ok(Readable.from([sdkArchive])));
+ ({ generates } = stubSdkGeneration(root));
+ await generates({ 'README.md': '# sdk' });
onPublishSdkError = sinon.stub();
});
@@ -140,6 +130,34 @@ describe('SdkPublishAction', () => {
expect(opened.firstCall.args[0].isEqual(sdk())).to.be.true;
});
+ // Markers would go up with the rest, and the version would be recorded as published.
+ it('publishes nothing, and ends as Action required, while the SDK still has conflicts to resolve', async () => {
+ const v1: Files = {
+ 'README.md': '# sdk',
+ 'models/book.py': 'class Book:\n # Price in USD.\n price: float\n'
+ };
+ fs.mkdirSync(sdk().toString(), { recursive: true });
+ (await new GitService().init(sdk()))._unsafeUnwrap();
+ writeFiles(sdk().toString(), v1);
+ (await new GitContext(sdk()).recordSnapshot(sdk(), Language.PYTHON, Stability.STABLE))._unsafeUnwrap();
+ writeFiles(sdk().toString(), { 'models/book.py': v1['models/book.py'].replace('USD', 'USD, VAT included') });
+ await generates({ ...v1, 'models/book.py': v1['models/book.py'].replace('USD', 'US dollars') });
+ sinon.stub(SdkGeneratePrompts.prototype, 'repositoryFound');
+ sinon.stub(SdkGeneratePrompts.prototype, 'conflictChoice').resolves('asPlaced');
+ sinon.stub(SdkGeneratePrompts.prototype, 'sdkMerged');
+ sinon.stub(SdkGeneratePrompts.prototype, 'conflictSteps');
+ const conflictsNotPublished = sinon.stub(SdkPublishPrompts.prototype, 'conflictsNotPublished');
+ const publishSdkPackage = sinon.stub(PublishingApiService.prototype, 'publishSdkPackage');
+
+ const result = await execute(false, { force: false, canAsk: true });
+
+ expect(result.isActionRequired()).to.be.true;
+ expect(conflictsNotPublished.calledOnce).to.be.true;
+ expect(publishSdkPackage.called).to.be.false;
+ expect(onPublishSdkError.called).to.be.false;
+ expect(fs.readFileSync(path.join(sdk().toString(), 'models', 'book.py'), 'utf8')).to.contain(YOURS_MARKER);
+ });
+
it('reports an SDK it could not zip, without publishing', async () => {
const problem: FileProblem = { kind: 'zipFailed', reason: 'EACCES: permission denied' };
const zip = sinon.stub(TempContext.prototype, 'zip').callThrough();
diff --git a/test/helpers/merge.ts b/test/helpers/merge.ts
new file mode 100644
index 000000000..622598a3d
--- /dev/null
+++ b/test/helpers/merge.ts
@@ -0,0 +1,47 @@
+import { Buffer } from 'node:buffer';
+import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js';
+import { BlobId } from '../../src/types/git/blob-id.js';
+import { StageEntry } from '../../src/types/git/merge-tree.js';
+import { Side } from '../../src/types/sdk/conflict-markers.js';
+import { Conflict, FileWrite, MergeFiles } from '../../src/types/sdk/conflict.js';
+
+export const SIDES: readonly Side[] = ['yours', 'generated'];
+
+export const NO_FILES: MergeFiles = { added: [], updated: [], merged: [], removed: [], kept: [] };
+
+export const rel = (value: string): RelativeFilePath => RelativeFilePath.fromGit(value)!;
+
+/** Text as bytes and back through latin1, one byte per character, so a fixture can hold any byte. */
+export const bytes = (text: string): Uint8Array => new Uint8Array(Buffer.from(text, 'latin1'));
+export const text = (content: Uint8Array): string => Buffer.from(content).toString('latin1');
+
+/** A blob id that spells a short `name`, for a fixture whose blobs are never read. */
+export const blob = (name: string): BlobId => BlobId.fromGit(Buffer.from(name).toString('hex').padEnd(40, '0'))!;
+
+/** A stage entry for `path`, holding a blob of its own. */
+export const stage = (path: string, at: StageEntry['stage']): StageEntry => ({
+ mode: '100644',
+ oid: blob(`${path}${at}`),
+ stage: at,
+ path: rel(path)
+});
+
+/** A file as git marks it when you and the generator changed the same lines, labelled with its two commits. */
+export const markedByGit = (yours: string, generated: string): string =>
+ `<<<<<<< ${'a'.repeat(40)}\n${yours}\n=======\n${generated}\n>>>>>>> ${'b'.repeat(40)}\n`;
+
+/** The summary's paths as strings, by kind. */
+export const named = (files: MergeFiles): Record =>
+ Object.fromEntries(Object.entries(files).map(([kind, paths]) => [kind, paths.map(String)]));
+
+/** How many paths the summary holds, by kind. */
+export const counts = (files: MergeFiles): Record =>
+ Object.fromEntries(Object.entries(files).map(([kind, paths]) => [kind, paths.length]));
+
+/** Each conflict as `[path, kind]`. */
+export const kinds = (conflicts: readonly Conflict[]): [string, string][] =>
+ conflicts.map((conflict) => [String(conflict.path), conflict.kind]);
+
+/** Each write as `[path, content]`, the content as text. */
+export const written = (writes: readonly FileWrite[]): [string, string][] =>
+ writes.map((write) => [String(write.path), text(write.content)]);
diff --git a/test/helpers/printed.ts b/test/helpers/printed.ts
new file mode 100644
index 000000000..cf4f1eceb
--- /dev/null
+++ b/test/helpers/printed.ts
@@ -0,0 +1,13 @@
+import { stripVTControlCharacters } from 'node:util';
+import sinon from 'sinon';
+
+/** What `say` writes to stdout, without colours: boxed or not depending on the terminal's width, but each line stays a line of its own. */
+export function printed(say: () => void): string {
+ const write = sinon.stub(process.stdout, 'write').returns(true);
+ try {
+ say();
+ } finally {
+ write.restore();
+ }
+ return stripVTControlCharacters(write.args.map(([chunk]) => String(chunk)).join(''));
+}
diff --git a/test/infrastructure/git-service.test.ts b/test/infrastructure/git-service.test.ts
index 2e24c184f..a4dff7a4c 100644
--- a/test/infrastructure/git-service.test.ts
+++ b/test/infrastructure/git-service.test.ts
@@ -7,15 +7,15 @@ import { execa } from 'execa';
import { GitService } from '../../src/infrastructure/git-service.js';
import { canonical } from '../../src/infrastructure/tmp-extensions.js';
import { DirectoryPath } from '../../src/types/file/directoryPath.js';
-import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js';
+import { BlobId } from '../../src/types/git/blob-id.js';
import { CommitId } from '../../src/types/git/commit-id.js';
+import { StageEntry } from '../../src/types/git/merge-tree.js';
import { TreeId } from '../../src/types/git/tree-id.js';
+import { rel } from '../helpers/merge.js';
import { describeCommit, snapshotFiles } from '../helpers/snapshots.js';
const REF = 'refs/apimatic/pristine';
-const rel = (value: string) => RelativeFilePath.fromGit(value)!;
-
describe('GitService', () => {
let root: string;
let git: GitService;
@@ -301,4 +301,71 @@ describe('GitService', () => {
expect(merged.messages).to.deep.equal([]);
});
});
+
+ describe('stageConflicts', () => {
+ const blobOf = async (repo: DirectoryPath, contents: string) => {
+ const { stdout } = await execa('git', ['hash-object', '-w', '--stdin'], {
+ cwd: repo.toString(),
+ input: contents
+ });
+ return BlobId.fromGit(stdout.trim())!;
+ };
+ const entry = (relative: string, stage: StageEntry['stage'], oid: BlobId): StageEntry => ({
+ mode: '100644',
+ oid,
+ stage,
+ path: rel(relative)
+ });
+ /** Every index entry as `[path, stage, oid]`. */
+ const indexOf = async (repo: DirectoryPath) => {
+ const { stdout } = await execa('git', ['ls-files', '--stage'], { cwd: repo.toString() });
+ return stdout
+ .trim()
+ .split('\n')
+ .map((line) => {
+ const [entry, relative] = line.split('\t');
+ const [, oid, stage] = entry.split(' ');
+ return [relative, Number(stage), oid];
+ });
+ };
+
+ it("puts each path into the repository's own index at the stages given, replacing what the index held for it", async () => {
+ const repo = await repository();
+ write(repo, 'same.txt', 'resolved\n');
+ write(repo, 'other.txt', 'other\n');
+ await execa('git', ['add', '.'], { cwd: repo.toString() });
+ const base = await blobOf(repo, 'base\n');
+ const ours = await blobOf(repo, 'ours\n');
+ const theirs = await blobOf(repo, 'theirs\n');
+
+ const staged = await git.stageConflicts(repo, [
+ entry('same.txt', 1, base),
+ entry('same.txt', 2, ours),
+ entry('same.txt', 3, theirs)
+ ]);
+
+ expect(staged.isOk()).to.be.true;
+ const index = await indexOf(repo);
+ expect(index.map(([relative, stage]) => [relative, stage])).to.have.deep.members([
+ ['other.txt', 0],
+ ['same.txt', 1],
+ ['same.txt', 2],
+ ['same.txt', 3]
+ ]);
+ expect(index.filter(([relative]) => relative === 'same.txt')).to.have.deep.members([
+ ['same.txt', 1, base.toString()],
+ ['same.txt', 2, ours.toString()],
+ ['same.txt', 3, theirs.toString()]
+ ]);
+ });
+
+ it('leaves the index alone when there is nothing to stage', async () => {
+ const repo = await repository();
+ write(repo, 'a.txt', 'a\n');
+ await execa('git', ['add', '.'], { cwd: repo.toString() });
+
+ expect((await git.stageConflicts(repo, [])).isOk()).to.be.true;
+ expect((await indexOf(repo)).map(([relative, stage]) => [relative, stage])).to.deep.equal([['a.txt', 0]]);
+ });
+ });
});
diff --git a/test/prompts/format.test.ts b/test/prompts/format.test.ts
index 747b45a92..61459cb4c 100644
--- a/test/prompts/format.test.ts
+++ b/test/prompts/format.test.ts
@@ -1,7 +1,24 @@
import path from 'node:path';
import { expect } from 'chai';
-import { format } from '../../src/prompts/format.js';
+import { ActionResult } from '../../src/actions/action-result.js';
+import { format, outro } from '../../src/prompts/format.js';
import { DirectoryPath } from '../../src/types/file/directoryPath.js';
+import { printed } from '../helpers/printed.js';
+
+describe('outro', () => {
+ it('closes a run that leaves the developer something to do as Action required, exiting 1', () => {
+ const exitCode = process.exitCode;
+
+ const said = printed(() => outro(ActionResult.actionRequired()));
+
+ try {
+ expect(said).to.contain('Action required');
+ expect(process.exitCode).to.equal(1);
+ } finally {
+ process.exitCode = exitCode;
+ }
+ });
+});
// `DirectoryPath` resolves on construction, so every path the CLI holds is absolute. The commands
// that print one for a reader to read or paste — `sdk generate`'s destination, the `cd` line in
diff --git a/test/prompts/sdk/generate.test.ts b/test/prompts/sdk/generate.test.ts
index ec5a6dd8a..cb2c923b3 100644
--- a/test/prompts/sdk/generate.test.ts
+++ b/test/prompts/sdk/generate.test.ts
@@ -7,10 +7,13 @@ import { SdkGeneratePrompts } from '../../../src/prompts/sdk/generate.js';
import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
import { GitVersion } from '../../../src/types/git/git-version.js';
import { CommitId } from '../../../src/types/git/commit-id.js';
-import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js';
-import { Conflict, MergeFiles } from '../../../src/types/sdk/merge-plan.js';
+import { MergedText } from '../../../src/types/sdk/conflict-markers.js';
+import { Conflict, MarkedConflict, MergeFiles, Settlement, WholeConflict } from '../../../src/types/sdk/conflict.js';
+import { MergePlan } from '../../../src/types/sdk/merge-plan.js';
import { WriteProblem } from '../../../src/types/sdk/write-problem.js';
import { Language } from '../../../src/types/sdk/generate.js';
+import { NO_FILES, rel } from '../../helpers/merge.js';
+import { printed } from '../../helpers/printed.js';
describe('SdkGeneratePrompts.languageNotAvailable', () => {
const prompts = new SdkGeneratePrompts();
@@ -141,13 +144,7 @@ describe('SdkGeneratePrompts, about the repository', () => {
});
it('gives the steps that keep edits, should the developer make any, when the SDK directory is not a repository', () => {
- const write = sinon.stub(process.stdout, 'write').returns(true);
- try {
- new SdkGeneratePrompts().gitInitSteps(sdk);
- } finally {
- write.restore();
- }
- const note = stripVTControlCharacters(write.args.map(([chunk]) => String(chunk)).join(''));
+ const note = printed(() => new SdkGeneratePrompts().gitInitSteps(sdk));
const order = [
'Next steps',
@@ -208,17 +205,15 @@ describe('SdkGeneratePrompts, about the repository', () => {
});
});
+/** A conflict git marked in the file; what the file holds, or which stages it has, does not matter to a prompt. */
+const marked = (path: string) => new MarkedConflict(rel(path), 'bothChanged', [], MergedText.parse(new Uint8Array()));
+const whole = (path: string, kind: 'deletedByYou' | 'noLongerGenerated' | 'binary') =>
+ new WholeConflict(rel(path), kind, [], [], Settlement.untouched(), Settlement.kept(rel(path)));
+const plan = (overrides: Partial = {}, conflicts: readonly Conflict[] = []) =>
+ new MergePlan([], [], conflicts, { ...NO_FILES, ...overrides });
+
describe('SdkGeneratePrompts, about a merge', () => {
const sdk = new DirectoryPath(process.cwd()).join('sdk', 'python');
- const rel = (value: string) => RelativeFilePath.fromGit(value)!;
- const files = (overrides: Partial = {}): MergeFiles => ({
- added: [],
- updated: [],
- merged: [],
- removed: [],
- kept: [],
- ...overrides
- });
afterEach(() => {
sinon.restore();
@@ -226,7 +221,7 @@ describe('SdkGeneratePrompts, about a merge', () => {
it('sums up what the generator did and names the files of yours it merged into or left alone, or says nothing changed', () => {
const info = sinon.stub(log, 'info');
- const merged = files({
+ const merged = plan({
added: [rel('models/author.py')],
updated: [rel('README.md'), rel('x.py')],
merged: [rel('models/book.py')],
@@ -248,23 +243,59 @@ describe('SdkGeneratePrompts, about a merge', () => {
);
});
- it('lists the conflicts with what each one is, and that nothing was written', () => {
- const write = sinon.stub(process.stdout, 'write').returns(true);
- const conflicts: Conflict[] = [
- { path: rel('models/book.py'), kind: 'bothChanged' },
- { path: rel('docs/book.md'), kind: 'deletedByYou' }
- ];
- try {
- new SdkGeneratePrompts().conflictsNotAsked(sdk, conflicts);
- } finally {
- write.restore();
- }
- const note = stripVTControlCharacters(write.args.map(([chunk]) => String(chunk)).join(''));
-
- expect(note).to.contain('Conflicts in 2 files');
- expect(note).to.contain('book.py').and.to.contain('changed by you and by the generator');
- expect(note).to.contain('book.md').and.to.contain('deleted by you, changed by the generator');
- expect(note).to.contain('Nothing was written.');
+ it('counts the conflicted files in the headline, so it never says nothing changed above a conflict', () => {
+ const info = sinon.stub(log, 'info');
+
+ new SdkGeneratePrompts().sdkMerged(sdk, plan({}, [whole('src/resources/authors.py', 'noLongerGenerated')]));
+ new SdkGeneratePrompts().sdkMerged(
+ sdk,
+ plan({ updated: [rel('README.md')] }, [marked('models/book.py'), marked('models/author.py')])
+ );
+
+ expect(stripVTControlCharacters(info.firstCall.args[0])).to.equal(
+ "Regenerated the SDK at './sdk/python': 1 file in conflict."
+ );
+ expect(stripVTControlCharacters(info.secondCall.args[0])).to.equal(
+ "Regenerated the SDK at './sdk/python': 1 file updated and 2 files in conflict."
+ );
+ });
+
+ it('says which side each conflict was resolved for, naming the files', () => {
+ const info = sinon.stub(log, 'info');
+
+ new SdkGeneratePrompts().conflictsResolved(sdk, 'generated', [marked('models/book.py')]);
+ new SdkGeneratePrompts().conflictsResolved(sdk, 'yours', [
+ marked('models/book.py'),
+ whole('docs/book.md', 'deletedByYou')
+ ]);
+
+ expect(stripVTControlCharacters(info.firstCall.args[0])).to.equal(
+ "Resolved using the generator's changes:\n './sdk/python/models/book.py'"
+ );
+ expect(stripVTControlCharacters(info.secondCall.args[0])).to.equal(
+ "Resolved using your changes:\n './sdk/python/models/book.py'\n './sdk/python/docs/book.md'"
+ );
+ });
+
+ it('says that nothing was written when the developer cancels at the question', () => {
+ const info = sinon.stub(log, 'info');
+
+ new SdkGeneratePrompts().runCancelled(sdk);
+
+ expect(stripVTControlCharacters(info.firstCall.args[0])).to.equal(
+ "Nothing was written; './sdk/python' is as it was."
+ );
+ });
+
+ it('warns that the next run will not know about the markers when git could not hold the files as unmerged', () => {
+ const warn = sinon.stub(log, 'warn');
+
+ new SdkGeneratePrompts().conflictsNotStaged('index locked');
+
+ expect(stripVTControlCharacters(warn.firstCall.args[0])).to.equal(
+ "The conflicted files could not be marked in git's index: index locked\n" +
+ 'The markers are in the files, but the next run will not know about them: resolve them before rerunning.'
+ );
});
it('says why a forced run replaced a recorded SDK, and where the committed customizations still are', () => {
@@ -331,3 +362,64 @@ describe('SdkGeneratePrompts, about a merge', () => {
);
});
});
+
+describe('SdkGeneratePrompts, about the conflicts', () => {
+ const sdk = new DirectoryPath(process.cwd()).join('sdk', 'python');
+ const conflicts: readonly Conflict[] = [marked('models/book.py'), whole('resources/reviews.py', 'noLongerGenerated')];
+ const conflicted = plan({}, conflicts);
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('lists the conflicted files with what happened to each, and nothing about how it is written', () => {
+ const note = printed(() => new SdkGeneratePrompts().conflictsNotAsked(sdk, conflicts));
+
+ expect(note).to.contain('2 files need your attention');
+ expect(note).to.contain('book.py').and.to.contain('changed by you and by the generator');
+ expect(note).to.contain('reviews.py').and.to.contain('no longer generated');
+ expect(note).to.not.contain('markers');
+ });
+
+ it('sends a run that could not ask to a terminal, or to --force for the generator’s changes', () => {
+ const note = printed(() => new SdkGeneratePrompts().conflictsNotAsked(sdk, conflicts));
+
+ expect(note).to.contain('Nothing was written.');
+ expect(note).to.contain('Rerun in a terminal to choose how they are resolved');
+ expect(note).to.contain("or with --force to resolve them using the generator's changes.");
+ });
+
+ it('speaks of one file in the singular', () => {
+ const note = printed(() => new SdkGeneratePrompts().conflictsNotAsked(sdk, [marked('models/book.py')]));
+
+ expect(note).to.contain('1 file needs your attention');
+ expect(note).to.contain('to choose how it is resolved');
+ expect(note).to.contain("to resolve it using the generator's changes.");
+ });
+
+ it('offers a merge tool once git holds the files as unmerged, and names the files with markers and without', () => {
+ const note = printed(() => new SdkGeneratePrompts().conflictSteps(sdk, conflicted, true));
+
+ const order = [
+ 'Next steps',
+ "1. Resolve them in a merge tool, such as 'git mergetool' or your editor's merge view, or by hand:",
+ 'With markers: keep what is right, remove the markers, and stage each file.',
+ "'./sdk/python/models/book.py'",
+ 'Without markers: decide whether each file stays or goes (delete it again, or keep it as your own), and stage that.',
+ "'./sdk/python/resources/reviews.py'",
+ '2. Rerun the command.'
+ ].map((line) => note.indexOf(line));
+ expect(order).to.not.include(-1);
+ expect(order).to.deep.equal([...order].sort((a, b) => a - b));
+ });
+
+ it('leaves the merge tool out when git does not hold the files as unmerged, and the group a plan does not have', () => {
+ const note = printed(() =>
+ new SdkGeneratePrompts().conflictSteps(sdk, plan({}, [marked('models/book.py')]), false)
+ );
+
+ expect(note).to.contain('1. Resolve it by hand:');
+ expect(note).to.not.contain('merge tool');
+ expect(note).to.not.contain('Without markers');
+ });
+});
diff --git a/test/prompts/sdk/publish.test.ts b/test/prompts/sdk/publish.test.ts
new file mode 100644
index 000000000..32d89f4c6
--- /dev/null
+++ b/test/prompts/sdk/publish.test.ts
@@ -0,0 +1,21 @@
+import { stripVTControlCharacters } from 'node:util';
+import { expect } from 'chai';
+import sinon from 'sinon';
+import { log } from '@clack/prompts';
+import { SdkPublishPrompts } from '../../../src/prompts/sdk/publish.js';
+
+describe('SdkPublishPrompts, about a generation left in conflict', () => {
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('says that nothing was published, and when to rerun', () => {
+ const warn = sinon.stub(log, 'warn');
+
+ new SdkPublishPrompts().conflictsNotPublished();
+
+ expect(stripVTControlCharacters(warn.firstCall.args[0])).to.equal(
+ 'Nothing was published, since the SDK still has conflicts. Rerun once they are resolved and staged.'
+ );
+ });
+});
diff --git a/test/types/git-context.test.ts b/test/types/git-context.test.ts
index 3a069f756..06f9e8b8e 100644
--- a/test/types/git-context.test.ts
+++ b/test/types/git-context.test.ts
@@ -10,13 +10,13 @@ import { FileService } from '../../src/infrastructure/file-service.js';
import { GitService } from '../../src/infrastructure/git-service.js';
import { canonical } from '../../src/infrastructure/tmp-extensions.js';
import { DirectoryPath } from '../../src/types/file/directoryPath.js';
-import { RelativeFilePath } from '../../src/types/file/relativeFilePath.js';
import { GitContext } from '../../src/types/git-context.js';
import { CommitId } from '../../src/types/git/commit-id.js';
import { GitVersion } from '../../src/types/git/git-version.js';
import { Language, Stability } from '../../src/types/sdk/generate.js';
import { recordContents } from '../../src/types/sdk/generation-record.js';
-import { MergePlan } from '../../src/types/sdk/merge-plan.js';
+import { GENERATED_MARKER, Side, YOURS_MARKER } from '../../src/types/sdk/conflict-markers.js';
+import { counts, kinds } from '../helpers/merge.js';
import { describeCommit, snapshotFiles } from '../helpers/snapshots.js';
/** Where the SDK directory's own repository keeps each generation as the server returned it. */
@@ -57,10 +57,6 @@ const MODEL_V2 = [
const HELPER = '\nexport function formatPrice(book: Book): string {\n return `$${book.price.toFixed(2)}`;\n}\n';
const MODEL_V1_WITH_HELPER = MODEL_V1 + HELPER;
-const rel = (value: string) => RelativeFilePath.fromGit(value)!;
-const counts = (plan: MergePlan) =>
- Object.fromEntries(Object.entries(plan.files).map(([kind, paths]) => [kind, paths.length]));
-
describe('GitContext', () => {
let root: string;
const git = new GitService();
@@ -340,13 +336,15 @@ describe('GitContext', () => {
/**
* `base` was generated last time, `disk` is the SDK directory now, `fresh` is the generator's new
* output: the plan, and the directory once the plan is written, unless the test reads the plan alone.
- * `beforeWrite` runs once the plan is made, for a test that breaks the write, which is then not unwrapped.
+ * `favour` settles the conflicts for that side, as the question's answer does, and the settled plan is the
+ * one written and returned. `beforeWrite` runs once the plan is made, for a test that breaks the write,
+ * which is then not unwrapped.
*/
const merged = async (
base: Files,
disk: Files,
fresh: Files,
- { written = true, beforeWrite }: { written?: boolean; beforeWrite?: () => void } = {}
+ { written = true, favour, beforeWrite }: { written?: boolean; favour?: Side; beforeWrite?: () => void } = {}
) => {
const directory = inRoot(`sdk-${counter++}`);
await ownRepository(directory);
@@ -355,7 +353,8 @@ describe('GitContext', () => {
write(path.join(directory, relative), contents);
}
const freshDirectory = generated(fresh);
- const plan = (await context(directory).merge(baseCommit, freshDirectory))._unsafeUnwrap();
+ const made = (await context(directory).merge(baseCommit, freshDirectory))._unsafeUnwrap();
+ const plan = favour === undefined ? made : made.taking(favour);
beforeWrite?.();
const previous = (await context(directory).keepPrevious(baseCommit))._unsafeUnwrap();
const landed =
@@ -413,19 +412,45 @@ describe('GitContext', () => {
expect(plan.hasConflicts()).to.be.false;
});
- it('reports the lines you and the generator both changed as a conflict', async () => {
+ it('marks the lines you and the generator both changed, with the CLI’s labels, and keeps the rest merged', async () => {
+ const yours = MODEL_V1.replace('Price in USD.', 'Price in USD, VAT included.');
+
+ const { plan, read } = await merged({ 'book.ts': MODEL_V1 }, { 'book.ts': yours }, { 'book.ts': MODEL_V2 });
+
+ expect(read('book.ts')).to.equal(
+ [
+ 'export type Book = {',
+ ...MODEL_FIELDS,
+ YOURS_MARKER,
+ ' /** Price in USD, VAT included. */',
+ '=======',
+ ' /** Retail price in US dollars. */',
+ GENERATED_MARKER,
+ ' price: number;',
+ ' isbn?: string;',
+ '};',
+ ''
+ ].join('\n')
+ );
+ expect(kinds(plan.conflicts)).to.deep.equal([['book.ts', 'bothChanged']]);
+ expect(plan.stages.map(({ stage, path: staged }) => [String(staged), stage])).to.deep.equal([
+ ['book.ts', 1],
+ ['book.ts', 2],
+ ['book.ts', 3]
+ ]);
+ });
+
+ it('leaves the directory alone when the plan is only read', async () => {
const yours = MODEL_V1.replace('Price in USD.', 'Price in USD, VAT included.');
const { plan, read } = await merged(
{ 'book.ts': MODEL_V1 },
{ 'book.ts': yours },
{ 'book.ts': MODEL_V2 },
- {
- written: false
- }
+ { written: false }
);
- expect(plan.conflicts).to.deep.equal([{ path: rel('book.ts'), kind: 'bothChanged' }]);
+ expect(plan.hasConflicts()).to.be.true;
expect(read('book.ts')).to.equal(yours);
});
@@ -443,12 +468,11 @@ describe('GitContext', () => {
expect(plan.files.kept.map(String)).to.deep.equal(['custom/format.ts']);
});
- it('reports a path you and the generator both added with different content as a conflict, and accepts one with the same', async () => {
+ it('marks a path you and the generator both added with different content, and accepts one with the same', async () => {
const differ = await merged(
{ 'a.ts': 'a\n' },
{ 'a.ts': 'a\n', 'retry.ts': 'yours\n' },
- { 'a.ts': 'a\n', 'retry.ts': 'generated\n' },
- { written: false }
+ { 'a.ts': 'a\n', 'retry.ts': 'generated\n' }
);
const same = await merged(
{ 'a.ts': 'a\n' },
@@ -456,7 +480,10 @@ describe('GitContext', () => {
{ 'a.ts': 'a\n', 'retry.ts': 'same\n' }
);
- expect(differ.plan.conflicts).to.deep.equal([{ path: rel('retry.ts'), kind: 'bothAdded' }]);
+ expect(differ.read('retry.ts')).to.equal(
+ [YOURS_MARKER, 'yours', '=======', 'generated', GENERATED_MARKER, ''].join('\n')
+ );
+ expect(kinds(differ.plan.conflicts)).to.deep.equal([['retry.ts', 'bothAdded']]);
expect(same.plan.writes).to.be.empty;
expect(same.plan.hasConflicts()).to.be.false;
});
@@ -473,6 +500,19 @@ describe('GitContext', () => {
expect(plan.hasConflicts()).to.be.false;
});
+ it('labels the markers in a file you moved with the sides, where git names the old path too', async () => {
+ const { plan, read } = await merged(
+ { 'models/book.ts': MODEL_V1, 'b.ts': 'b\n' },
+ { 'models/catalog/book.ts': MODEL_V1.replace('Price in USD.', 'Price in USD, VAT included.'), 'b.ts': 'b\n' },
+ { 'models/book.ts': MODEL_V2, 'b.ts': 'b\n' }
+ );
+
+ const content = read('models/catalog/book.ts')!;
+ expect(content).to.contain(`${YOURS_MARKER}\n`).and.to.contain(`${GENERATED_MARKER}\n`);
+ expect(content).to.not.match(/^[<>]{7} [0-9a-f]{40}/m);
+ expect(kinds(plan.conflicts)).to.deep.equal([['models/catalog/book.ts', 'bothChanged']]);
+ });
+
it('carries your edit to a file the generator renamed, and removes the old name', async () => {
const { plan, read, exists } = await merged(
{ 'models/book.ts': MODEL_V1, 'b.ts': 'b\n' },
@@ -496,6 +536,100 @@ describe('GitContext', () => {
expect(read('models/User.ts')).to.equal('user\n');
});
+ it('neither lists nor brings back the old name of a file the generator renamed and you deleted', async () => {
+ const { plan, paths, read } = await merged(
+ { 'models/book.ts': MODEL_V1, 'b.ts': 'b\n' },
+ { 'b.ts': 'b\n' },
+ { 'models/book-model.ts': MODEL_V2, 'b.ts': 'b\n' }
+ );
+
+ expect(kinds(plan.conflicts)).to.deep.equal([['models/book-model.ts', 'deletedByYou']]);
+ expect(paths()).to.deep.equal(['b.ts', 'models/book-model.ts']);
+ expect(read('models/book-model.ts')).to.equal(MODEL_V2);
+ });
+
+ describe('settled for a side, as the question’s answer is', () => {
+ it('settles each overlapping hunk for that side and merges the rest, with the file’s own line ending', async () => {
+ const base = 'a\r\nb\r\nc\r\nd\r\ne\r\nf\r\ng\r\n';
+ const yours = 'A\r\nb\r\nc\r\nd\r\ne\r\nf\r\ng yours\r\n';
+ const generated = 'a\nb\nc\nD\ne\nf\ng generated\n';
+ const expected = {
+ generated: 'A\r\nb\r\nc\r\nD\r\ne\r\nf\r\ng generated\r\n',
+ yours: 'A\r\nb\r\nc\r\nD\r\ne\r\nf\r\ng yours\r\n'
+ };
+ for (const favour of ['generated', 'yours'] as const) {
+ const { plan, read } = await merged({ 'a.ts': base }, { 'a.ts': yours }, { 'a.ts': generated }, { favour });
+
+ expect(read('a.ts'), favour).to.equal(expected[favour]);
+ expect(plan.hasConflicts(), favour).to.be.false;
+ expect(plan.files.merged.map(String), favour).to.deep.equal(['a.ts']);
+ }
+ });
+
+ it('keeps an edited file the generator stopped producing, for the generator’s side too', async () => {
+ const { plan, read } = await merged(
+ { 'models/book.ts': MODEL_V1, 'helper.ts': 'helper\n' },
+ { 'models/book.ts': MODEL_V1_WITH_HELPER, 'helper.ts': 'helper, edited\n' },
+ { 'models/book.ts': MODEL_V2 },
+ { favour: 'generated' }
+ );
+
+ expect(read('helper.ts')).to.equal('helper, edited\n');
+ expect(read('models/book.ts')).to.equal(MODEL_V2 + HELPER);
+ expect(plan.files.kept.map(String)).to.deep.equal(['helper.ts']);
+ expect(plan.hasConflicts()).to.be.false;
+ });
+
+ it('keeps a file you added inside a directory the generator renamed, for either side', async () => {
+ for (const favour of ['generated', 'yours'] as const) {
+ const { plan, paths, read } = await merged(
+ { 'models/book.ts': MODEL_V1, 'b.ts': 'b\n' },
+ { 'models/book.ts': MODEL_V1, 'models/custom.ts': 'custom\n', 'b.ts': 'b\n' },
+ { 'model/book.ts': MODEL_V2, 'b.ts': 'b\n' },
+ { favour }
+ );
+
+ expect(paths(), favour).to.deep.equal(['b.ts', 'model/book.ts', 'model/custom.ts']);
+ expect(read('model/custom.ts')).to.equal('custom\n');
+ expect(read('model/book.ts')).to.equal(MODEL_V2);
+ expect(plan.hasConflicts(), favour).to.be.false;
+ }
+ });
+
+ it('reports a file you added inside a directory the generator renamed as a conflict of another shape, placed in the new directory', async () => {
+ const { plan, paths, read } = await merged(
+ { 'models/book.ts': MODEL_V1, 'b.ts': 'b\n' },
+ { 'models/book.ts': MODEL_V1, 'models/custom.ts': 'custom\n', 'b.ts': 'b\n' },
+ { 'model/book.ts': MODEL_V2, 'b.ts': 'b\n' }
+ );
+
+ expect(kinds(plan.conflicts)).to.deep.equal([['model/custom.ts', 'other']]);
+ expect(paths()).to.deep.equal(['b.ts', 'model/book.ts', 'model/custom.ts']);
+ expect(read('model/custom.ts')).to.equal('custom\n');
+ expect(counts(plan.files)).to.deep.equal({ added: 0, updated: 1, merged: 0, removed: 1, kept: 0 });
+ });
+ });
+
+ describe('stageConflicts', () => {
+ it('marks the conflicted paths in the repository’s own index at stages 1, 2 and 3, and nothing else', async () => {
+ const yours = MODEL_V1.replace('Price in USD.', 'Price in USD, VAT included.');
+ const { plan, directory } = await merged(
+ { 'book.ts': MODEL_V1, 'b.ts': 'b\n' },
+ { 'book.ts': yours, 'b.ts': 'b\n' },
+ { 'book.ts': MODEL_V2, 'b.ts': 'b\n' }
+ );
+
+ (await context(directory).stageConflicts(plan))._unsafeUnwrap();
+
+ const { stdout } = await execa('git', ['ls-files', '--stage'], { cwd: directory });
+ expect(stdout.split('\n').map((line) => [line.split('\t')[1], line.split(/\s+/)[2]])).to.deep.equal([
+ ['book.ts', '1'],
+ ['book.ts', '2'],
+ ['book.ts', '3']
+ ]);
+ });
+ });
+
describe('two spec versions in a row, as a generator ships them', () => {
const v1: Files = {
'src/models/book.ts': MODEL_V1,
@@ -509,37 +643,45 @@ describe('GitContext', () => {
'docs/models/book.md': '# Book\n\nprice\nisbn\n'
};
- it('counts a helper, a new file and a custom file through the v2 spec, and reports a deleted docs page as a conflict', async () => {
- const { plan } = await merged(
+ it('keeps a helper, a new file and a custom file through the v2 spec, and writes back a deleted docs page as a conflict', async () => {
+ const { plan, read, paths } = await merged(
v1,
{
'src/models/book.ts': MODEL_V1_WITH_HELPER,
'src/sdk/authors.ts': 'export class Authors {}\n',
'src/custom/format.ts': 'mine\n'
},
- v2,
- { written: false }
+ v2
);
- expect(plan.conflicts).to.deep.equal([{ path: rel('docs/models/book.md'), kind: 'deletedByYou' }]);
- expect(counts(plan)).to.deep.equal({ added: 1, updated: 0, merged: 1, removed: 0, kept: 1 });
+ expect(paths()).to.deep.equal([
+ 'docs/models/book.md',
+ 'src/custom/format.ts',
+ 'src/funcs/delete-book.ts',
+ 'src/models/book.ts',
+ 'src/sdk/authors.ts'
+ ]);
+ expect(read('src/models/book.ts')).to.equal(MODEL_V2 + HELPER);
+ expect(kinds(plan.conflicts)).to.deep.equal([['docs/models/book.md', 'deletedByYou']]);
+ expect(counts(plan.files)).to.deep.equal({ added: 1, updated: 0, merged: 1, removed: 0, kept: 1 });
});
- it('reports an edited file the v3 spec drops as a conflict, and removes nothing else it did not produce', async () => {
+ it('keeps an edited file the v3 spec drops, and removes nothing else it did not produce', async () => {
const v3: Files = { 'src/models/book.ts': MODEL_V2, 'docs/models/book.md': '# Book\n\nprice\nisbn\n' };
- const { plan } = await merged(
+ const { plan, read, exists } = await merged(
v1,
{
'src/models/book.ts': MODEL_V1,
'src/sdk/authors.ts': 'export class Authors { getMany() {} }\n',
'docs/models/book.md': '# Book\n\nprice\n'
},
- v3,
- { written: false }
+ v3
);
- expect(plan.conflicts).to.deep.equal([{ path: rel('src/sdk/authors.ts'), kind: 'noLongerGenerated' }]);
+ expect(exists('src/sdk/authors.ts')).to.be.true;
+ expect(read('src/models/book.ts')).to.equal(MODEL_V2);
+ expect(kinds(plan.conflicts)).to.deep.equal([['src/sdk/authors.ts', 'noLongerGenerated']]);
expect(plan.files.updated).to.have.length(2);
expect(plan.deletes).to.deep.equal([]);
});
@@ -651,24 +793,36 @@ describe('GitContext', () => {
expect(read('d.cs')).to.equal('new\r\n');
});
- it('replaces an unedited binary and reports an edited one the generator changed too as a conflict', async () => {
+ it('labels the markers of a CRLF file on CRLF lines', async () => {
+ const { read } = await merged(
+ { 'a.cs': 'one\r\ntwo\r\n' },
+ { 'a.cs': 'one\r\nmine\r\n' },
+ { 'a.cs': 'one\r\ntheirs\r\n' }
+ );
+
+ expect(read('a.cs')).to.equal(`one\r\n${YOURS_MARKER}\r\nmine\r\n=======\r\ntheirs\r\n${GENERATED_MARKER}\r\n`);
+ });
+
+ it('replaces an unedited binary and keeps an edited one, as a conflict when the generator changed it too', async () => {
const changed = Buffer.concat([BINARY, Buffer.from([1])]);
const edited = Buffer.concat([BINARY, Buffer.from([2])]);
const replaced = await merged({ 'logo.png': BINARY }, { 'logo.png': BINARY }, { 'logo.png': changed });
- const both = await merged(
+ const both = await merged({ 'logo.png': BINARY }, { 'logo.png': edited }, { 'logo.png': changed });
+ const settled = await merged(
{ 'logo.png': BINARY },
{ 'logo.png': edited },
{ 'logo.png': changed },
- {
- written: false
- }
+ { favour: 'generated' }
);
expect(replaced.bytes('logo.png')).to.deep.equal(changed);
expect(replaced.plan.files.updated).to.have.length(1);
expect(both.bytes('logo.png')).to.deep.equal(edited);
- expect(both.plan.conflicts).to.deep.equal([{ path: rel('logo.png'), kind: 'binary' }]);
+ expect(kinds(both.plan.conflicts)).to.deep.equal([['logo.png', 'binary']]);
+ expect(settled.bytes('logo.png')).to.deep.equal(changed);
+ expect(settled.plan.files.updated.map(String)).to.deep.equal(['logo.png']);
+ expect(settled.plan.hasConflicts()).to.be.false;
});
});
});
diff --git a/test/types/sdk/conflict-markers.test.ts b/test/types/sdk/conflict-markers.test.ts
new file mode 100644
index 000000000..85a921355
--- /dev/null
+++ b/test/types/sdk/conflict-markers.test.ts
@@ -0,0 +1,111 @@
+import { expect } from 'chai';
+import { GENERATED_MARKER, MergedText, Side, YOURS_MARKER } from '../../../src/types/sdk/conflict-markers.js';
+import { bytes, markedByGit, SIDES, text } from '../../helpers/merge.js';
+
+const OURS = 'a'.repeat(40);
+const THEIRS = 'b'.repeat(40);
+
+const relabelled = (content: string) => text(MergedText.parse(bytes(content)).relabelled());
+const settled = (content: string, side: Side) => text(MergedText.parse(bytes(content)).settledFor(side));
+/** A block as the CLI labels it. */
+const block = (yours: string[], generated: string[]) =>
+ [YOURS_MARKER, ...yours, '=======', ...generated, GENERATED_MARKER].join('\n');
+
+describe('MergedText.relabelled', () => {
+ it('names the sides where git named its commits, and changes nothing else', () => {
+ expect(relabelled(`a\n${markedByGit('B', 'X')}c\n`)).to.equal(
+ `a\n${YOURS_MARKER}\nB\n=======\nX\n${GENERATED_MARKER}\nc\n`
+ );
+ });
+
+ it('returns the same bytes when there is nothing to relabel', () => {
+ const content = bytes('<<<<<<< not a sha\nplain\n');
+
+ const parsed = MergedText.parse(content);
+
+ expect(parsed.hasMarkers()).to.be.false;
+ expect(parsed.relabelled()).to.equal(content);
+ });
+
+ it('reads the labels git writes after a rename, which name the path too, and on CRLF lines', () => {
+ expect(
+ relabelled(`<<<<<<< ${OURS}:old/path.ts\r\nB\r\n=======\r\nX\r\n>>>>>>> ${THEIRS}:new/path.ts\r\n`)
+ ).to.equal(`${YOURS_MARKER}\r\nB\r\n=======\r\nX\r\n${GENERATED_MARKER}\r\n`);
+ });
+
+ it('keeps every byte of a file that is not UTF-8, relabelling only the marker lines', () => {
+ expect(relabelled(`caf\xe9\n${markedByGit('\xff\xfe', 'X')}`)).to.equal(
+ `caf\xe9\n${YOURS_MARKER}\n\xff\xfe\n=======\nX\n${GENERATED_MARKER}\n`
+ );
+ });
+
+ it('keeps a ======= line outside a block as a line of the file, such as a Markdown heading underline', () => {
+ const content = `Title\n=======\n<<<<<<< ${OURS}\nmine\n=======\ntheirs\n=======\n>>>>>>> ${THEIRS}\n`;
+
+ expect(relabelled(content)).to.equal(
+ `Title\n=======\n${YOURS_MARKER}\nmine\n=======\ntheirs\n=======\n${GENERATED_MARKER}\n`
+ );
+ expect(settled(content, 'generated')).to.equal('Title\n=======\ntheirs\n=======\n');
+ expect(settled(content, 'yours')).to.equal('Title\n=======\nmine\n');
+ });
+});
+
+describe('MergedText.settledFor', () => {
+ const content = [
+ 'before',
+ block(['mine 1'], ['theirs 1']),
+ 'between',
+ block(['mine 2a', 'mine 2b'], ['theirs 2']),
+ 'after',
+ ''
+ ].join('\n');
+
+ it('keeps the side asked for in every block, and every line outside the blocks', () => {
+ expect(settled(content, 'generated')).to.equal('before\ntheirs 1\nbetween\ntheirs 2\nafter\n');
+ expect(settled(content, 'yours')).to.equal('before\nmine 1\nbetween\nmine 2a\nmine 2b\nafter\n');
+ });
+
+ it('reads the labels git writes as well as the CLI’s, so a file settles before or after it is relabelled', () => {
+ expect(settled(markedByGit('mine', 'theirs'), 'generated')).to.equal('theirs\n');
+ });
+
+ it('reads markers on CRLF lines, keeping the line endings', () => {
+ expect(
+ settled(`a\r\n${YOURS_MARKER}\r\nmine\r\n=======\r\ntheirs\r\n${GENERATED_MARKER}\r\nb\r\n`, 'yours')
+ ).to.equal('a\r\nmine\r\nb\r\n');
+ });
+
+ it('settles the markers in a file that is not UTF-8, keeping its other bytes', () => {
+ expect(settled(`caf\xe9\n${block(['\xff'], ['\xfe'])}\n`, 'generated')).to.equal('caf\xe9\n\xfe\n');
+ });
+
+ it('gives a file without markers back as it is, for either side, whatever its line endings', () => {
+ for (const plain of ['no markers\n', 'a\r\nb\r\n', 'a\rb\r', '']) {
+ const content = bytes(plain);
+ const parsed = MergedText.parse(content);
+
+ expect(parsed.hasMarkers(), JSON.stringify(plain)).to.be.false;
+ for (const side of SIDES) {
+ expect(parsed.settledFor(side), `${JSON.stringify(plain)} for ${side}`).to.equal(content);
+ }
+ }
+ });
+
+ it('leaves a file whole when a marker line was removed by hand, or a block is opened inside another, for either side', () => {
+ const noMiddle = `${YOURS_MARKER}\nmine\ntheirs\n${GENERATED_MARKER}\n`;
+ const noEnd = `${YOURS_MARKER}\nmine\n=======\ntheirs\n`;
+ const noStart = `mine\n=======\ntheirs\n${GENERATED_MARKER}\n`;
+ const openedTwice = `${YOURS_MARKER}\nmine\n${YOURS_MARKER}\nmore\n=======\ntheirs\n${GENERATED_MARKER}\n`;
+
+ for (const damaged of [noMiddle, noEnd, noStart, openedTwice]) {
+ const content = bytes(damaged);
+ const parsed = MergedText.parse(content);
+
+ expect(parsed.hasMarkers(), damaged).to.be.false;
+ expect(parsed.relabelled(), damaged).to.equal(content);
+ for (const side of SIDES) {
+ expect(parsed.settledFor(side), `${damaged} for ${side}`).to.equal(content);
+ }
+ }
+ });
+});
diff --git a/test/types/sdk/conflict.test.ts b/test/types/sdk/conflict.test.ts
new file mode 100644
index 000000000..84bbdfa6a
--- /dev/null
+++ b/test/types/sdk/conflict.test.ts
@@ -0,0 +1,110 @@
+import { expect } from 'chai';
+import { GENERATED_MARKER, MergedText, YOURS_MARKER } from '../../../src/types/sdk/conflict-markers.js';
+import {
+ Conflict,
+ FileWrite,
+ MarkedConflict,
+ MergeFiles,
+ PlacedConflict,
+ Settlement,
+ WholeConflict
+} from '../../../src/types/sdk/conflict.js';
+import { bytes, markedByGit, named, NO_FILES, rel, SIDES, written } from '../../helpers/merge.js';
+
+describe('MarkedConflict', () => {
+ const marked = new MarkedConflict(
+ rel('a.ts'),
+ 'bothChanged',
+ [],
+ MergedText.parse(bytes(`top\n${markedByGit('mine', 'theirs')}`))
+ );
+
+ it('lands with the CLI’s labels when resolved by hand', () => {
+ expect(marked.leavesMarkers()).to.be.true;
+ expect(written(marked.asPlaced())).to.deep.equal([
+ ['a.ts', `top\n${YOURS_MARKER}\nmine\n=======\ntheirs\n${GENERATED_MARKER}\n`]
+ ]);
+ });
+
+ it('settles each block for the side chosen, counted among the merged files', () => {
+ expect(written(marked.settledFor('yours').writes())).to.deep.equal([['a.ts', 'top\nmine\n']]);
+ expect(written(marked.settledFor('generated').writes())).to.deep.equal([['a.ts', 'top\ntheirs\n']]);
+ expect(named(marked.settledFor('generated').countedIn(NO_FILES))).to.deep.equal({
+ ...named(NO_FILES),
+ merged: ['a.ts']
+ });
+ });
+});
+
+describe('WholeConflict', () => {
+ const generated: FileWrite = { path: rel('a.ts'), content: bytes('generated') };
+
+ it('takes the generator’s version for its side where it has one, and keeps yours for your side', () => {
+ const binary = new WholeConflict(
+ rel('a.ts'),
+ 'binary',
+ [],
+ [],
+ Settlement.updated(generated),
+ Settlement.kept(rel('a.ts'))
+ );
+
+ expect(binary.leavesMarkers()).to.be.false;
+ expect(binary.settledFor('generated').writes()).to.deep.equal([generated]);
+ expect(named(binary.settledFor('generated').countedIn(NO_FILES)).updated).to.deep.equal(['a.ts']);
+ expect(binary.settledFor('yours').writes()).to.deep.equal([]);
+ expect(named(binary.settledFor('yours').countedIn(NO_FILES)).kept).to.deep.equal(['a.ts']);
+ });
+
+ it('keeps an edited file the generator no longer produces, for the generator’s side too', () => {
+ const kept = Settlement.kept(rel('a.ts'));
+ const dropped = new WholeConflict(rel('a.ts'), 'noLongerGenerated', [], [], kept, kept);
+
+ for (const side of SIDES) {
+ expect(dropped.settledFor(side).writes()).to.deep.equal([]);
+ expect(named(dropped.settledFor(side).countedIn(NO_FILES)).kept).to.deep.equal(['a.ts']);
+ }
+ });
+
+ it('lets a deletion of yours stand for your side, saying nothing, and writes the generator’s version for its own', () => {
+ const deleted = new WholeConflict(
+ rel('a.ts'),
+ 'deletedByYou',
+ [],
+ [generated],
+ Settlement.updated(generated),
+ Settlement.untouched()
+ );
+
+ expect(deleted.asPlaced()).to.deep.equal([generated]);
+ expect(deleted.settledFor('yours').writes()).to.deep.equal([]);
+ expect(deleted.settledFor('yours').countedIn(NO_FILES)).to.deep.equal(NO_FILES);
+ expect(deleted.settledFor('generated').writes()).to.deep.equal([generated]);
+ });
+});
+
+describe('PlacedConflict', () => {
+ it('keeps git’s placement for either side, counting nothing', () => {
+ const placed: FileWrite = { path: rel('model/custom.ts'), content: bytes('custom') };
+ const other: Conflict = new PlacedConflict(rel('model/custom.ts'), [], [placed]);
+
+ expect(other.kind).to.equal('other');
+ expect(other.leavesMarkers()).to.be.false;
+ expect(other.asPlaced()).to.deep.equal([placed]);
+ for (const side of SIDES) {
+ expect(other.settledFor(side).writes()).to.deep.equal([placed]);
+ expect(other.settledFor(side).countedIn(NO_FILES)).to.deep.equal(NO_FILES);
+ }
+ });
+});
+
+describe('Settlement', () => {
+ it('counts its path into the summary after the paths already there, leaving the summary given as it was', () => {
+ const files: MergeFiles = { ...NO_FILES, kept: [rel('mine.ts')] };
+
+ const counted = Settlement.kept(rel('a.ts')).countedIn(files);
+
+ expect(named(counted)).to.deep.equal({ ...named(NO_FILES), kept: ['mine.ts', 'a.ts'] });
+ expect(named(files).kept).to.deep.equal(['mine.ts']);
+ });
+});
diff --git a/test/types/sdk/merge-plan.test.ts b/test/types/sdk/merge-plan.test.ts
index ae3b319e9..24de95d92 100644
--- a/test/types/sdk/merge-plan.test.ts
+++ b/test/types/sdk/merge-plan.test.ts
@@ -1,15 +1,13 @@
-import { Buffer } from 'node:buffer';
import { expect } from 'chai';
-import { RelativeFilePath } from '../../../src/types/file/relativeFilePath.js';
-import { BlobId } from '../../../src/types/git/blob-id.js';
-import { MergeTreeOutput } from '../../../src/types/git/merge-tree.js';
+import { ValueMap } from '../../../src/types/common/value-map.js';
+import { MergeMessage, MergeTreeOutput } from '../../../src/types/git/merge-tree.js';
import { TreeId } from '../../../src/types/git/tree-id.js';
+import { GENERATED_MARKER, YOURS_MARKER } from '../../../src/types/sdk/conflict-markers.js';
+import { FileWrite } from '../../../src/types/sdk/conflict.js';
import { MergeInputs, MergePlan } from '../../../src/types/sdk/merge-plan.js';
+import { blob, bytes, counts, kinds, markedByGit, named, rel, SIDES, stage, written } from '../../helpers/merge.js';
const TREE = TreeId.fromGit('c'.repeat(40))!;
-const blob = (name: string) => BlobId.fromGit(Buffer.from(name).toString('hex').padEnd(40, '0'))!;
-const rel = (value: string) => RelativeFilePath.fromGit(value)!;
-const bytes = (text: string) => new Uint8Array(Buffer.from(text));
const merge = (overrides: Partial = {}): MergeTreeOutput => ({
tree: TREE,
@@ -25,11 +23,101 @@ const inputs = (overrides: Partial): MergeInputs => ({
writes: [],
deletes: [],
basePaths: [],
+ generatedVersions: new ValueMap(),
...overrides
});
-const counts = (plan: MergePlan) =>
- Object.fromEntries(Object.entries(plan.files).map(([kind, paths]) => [kind, paths.length]));
+const conflict = (kind: MergeMessage['kind'], ...paths: string[]): MergeMessage => ({
+ paths: paths.map(rel),
+ kind,
+ message: `CONFLICT: ${paths.join(', ')}`
+});
+
+/** Five conflicts of every kind but `other`, a clean file beside them, and one the generator dropped that you never touched. */
+const CONFLICTED: MergeInputs = inputs({
+ merge: merge({
+ stages: [
+ stage('logo.png', 1),
+ stage('logo.png', 2),
+ stage('logo.png', 3),
+ stage('deleted-by-you.ts', 1),
+ stage('deleted-by-you.ts', 3),
+ stage('dropped.ts', 1),
+ stage('dropped.ts', 2)
+ ],
+ messages: [
+ { paths: ['same.ts'].map(rel), kind: 'informational', message: 'Auto-merging same.ts' },
+ conflict('contents', 'same.ts'),
+ conflict('addAdd', 'added.ts'),
+ conflict('binary', 'logo.png'),
+ conflict('contents', 'logo.png'),
+ conflict('modifyDelete', 'deleted-by-you.ts'),
+ conflict('modifyDelete', 'dropped.ts')
+ ]
+ }),
+ basePaths: ['same.ts', 'logo.png', 'deleted-by-you.ts', 'dropped.ts', 'other.ts', 'gone.ts'].map(rel),
+ yours: [
+ { status: 'M', path: rel('same.ts'), blob: blob('sy') },
+ { status: 'A', path: rel('added.ts'), blob: blob('ay') },
+ { status: 'M', path: rel('logo.png'), blob: blob('ly') },
+ { status: 'D', path: rel('deleted-by-you.ts') },
+ { status: 'M', path: rel('dropped.ts'), blob: blob('dy') }
+ ],
+ generated: [
+ { status: 'M', path: rel('same.ts'), blob: blob('sg') },
+ { status: 'A', path: rel('added.ts'), blob: blob('ag') },
+ { status: 'M', path: rel('logo.png'), blob: blob('lg') },
+ { status: 'M', path: rel('deleted-by-you.ts'), blob: blob('bg') },
+ { status: 'D', path: rel('dropped.ts') },
+ { status: 'M', path: rel('other.ts'), blob: blob('og') },
+ { status: 'D', path: rel('gone.ts') }
+ ],
+ writes: [
+ { path: rel('same.ts'), content: bytes(markedByGit('same mine', 'same theirs')) },
+ { path: rel('added.ts'), content: bytes(markedByGit('added mine', 'added theirs')) },
+ { path: rel('deleted-by-you.ts'), content: bytes('bg') },
+ { path: rel('other.ts'), content: bytes('og') }
+ ],
+ deletes: ['gone.ts'].map(rel),
+ generatedVersions: new ValueMap([
+ [rel('logo.png'), bytes('lg')],
+ [rel('deleted-by-you.ts'), bytes('bg')]
+ ])
+});
+
+const conflicted = (more: Partial = {}) => MergePlan.from({ ...CONFLICTED, ...more });
+
+/** A file the generator renamed and you deleted, which git names by both names. */
+const renamedAway = () =>
+ MergePlan.from(
+ inputs({
+ merge: merge({
+ stages: [stage('new.ts', 1), stage('new.ts', 3)],
+ messages: [conflict('renameDelete', 'old.ts', 'new.ts')]
+ }),
+ basePaths: ['old.ts'].map(rel),
+ yours: [{ status: 'D', path: rel('old.ts') }],
+ generated: [{ status: 'R', from: rel('old.ts'), path: rel('new.ts'), blob: blob('n') }],
+ writes: [{ path: rel('new.ts'), content: bytes('n') }],
+ generatedVersions: new ValueMap([[rel('new.ts'), bytes('n')]])
+ })
+ );
+
+/** A file you added inside a directory the generator renamed, which the merge moves along and git names by both names. */
+const movedWithDirectory = () =>
+ MergePlan.from(
+ inputs({
+ merge: merge({ messages: [conflict('otherConflict', 'model/custom.ts', 'models/custom.ts')] }),
+ basePaths: ['models/book.ts'].map(rel),
+ yours: [{ status: 'A', path: rel('models/custom.ts'), blob: blob('c') }],
+ generated: [{ status: 'R', from: rel('models/book.ts'), path: rel('model/book.ts'), blob: blob('b') }],
+ writes: [
+ { path: rel('model/book.ts'), content: bytes('b') },
+ { path: rel('model/custom.ts'), content: bytes('c') }
+ ],
+ deletes: ['models/book.ts', 'models/custom.ts'].map(rel)
+ })
+ );
describe('MergePlan.from', () => {
it('passes the writes and deletions through, counted by who changed what', () => {
@@ -61,58 +149,75 @@ describe('MergePlan.from', () => {
{ path: rel('new.ts'), content: bytes('ng') }
]);
expect(plan.deletes).to.deep.equal(['d.ts'].map(rel));
- expect(counts(plan)).to.deep.equal({ added: 1, updated: 1, merged: 1, removed: 1, kept: 1 });
+ expect(counts(plan.files)).to.deep.equal({ added: 1, updated: 1, merged: 1, removed: 1, kept: 1 });
expect(plan.hasConflicts()).to.be.false;
expect(plan.isEmpty()).to.be.false;
});
it('names each conflict by its kind, from the message and from which stages are present', () => {
- const plan = MergePlan.from(
- inputs({
- merge: merge({
- stages: [
- { mode: '100644', oid: blob('1'), stage: 1, path: rel('deleted-by-you.ts') },
- { mode: '100644', oid: blob('3'), stage: 3, path: rel('deleted-by-you.ts') },
- { mode: '100644', oid: blob('1'), stage: 1, path: rel('dropped.ts') },
- { mode: '100644', oid: blob('2'), stage: 2, path: rel('dropped.ts') }
- ],
- messages: [
- { paths: ['same.ts'].map(rel), kind: 'informational', message: 'Auto-merging same.ts' },
- { paths: ['same.ts'].map(rel), kind: 'contents', message: 'CONFLICT (content): Merge conflict in same.ts' },
- { paths: ['added.ts'].map(rel), kind: 'addAdd', message: 'CONFLICT (add/add): Merge conflict in added.ts' },
- { paths: ['logo.png'].map(rel), kind: 'binary', message: 'warning: Cannot merge binary files: logo.png' },
- {
- paths: ['logo.png'].map(rel),
- kind: 'contents',
- message: 'CONFLICT (content): Merge conflict in logo.png'
- },
- { paths: ['deleted-by-you.ts'].map(rel), kind: 'modifyDelete', message: 'CONFLICT (modify/delete): …' },
- { paths: ['dropped.ts'].map(rel), kind: 'modifyDelete', message: 'CONFLICT (modify/delete): …' },
- {
- paths: ['x.ts', 'y.ts'].map(rel),
- kind: 'otherConflict',
- message: 'CONFLICT (rename/rename): a.ts renamed to x.ts and y.ts'
- }
- ]
- }),
- writes: [
- { path: rel('same.ts'), content: bytes('m') },
- { path: rel('deleted-by-you.ts'), content: bytes('3') }
- ]
- })
- );
+ const other: FileWrite[] = [
+ { path: rel('x.ts'), content: bytes('x') },
+ { path: rel('y.ts'), content: bytes('y') }
+ ];
+ const plan = conflicted({
+ merge: merge({
+ stages: CONFLICTED.merge.stages,
+ messages: [...CONFLICTED.merge.messages, conflict('otherConflict', 'x.ts', 'y.ts')]
+ }),
+ writes: [...CONFLICTED.writes, ...other]
+ });
- expect(plan.conflicts).to.deep.equal([
- { path: rel('same.ts'), kind: 'bothChanged' },
- { path: rel('added.ts'), kind: 'bothAdded' },
- { path: rel('logo.png'), kind: 'binary' },
- { path: rel('deleted-by-you.ts'), kind: 'deletedByYou' },
- { path: rel('dropped.ts'), kind: 'noLongerGenerated' },
- { path: rel('x.ts'), kind: 'other' },
- { path: rel('y.ts'), kind: 'other' }
+ expect(kinds(plan.conflicts)).to.deep.equal([
+ ['same.ts', 'bothChanged'],
+ ['added.ts', 'bothAdded'],
+ ['logo.png', 'binary'],
+ ['deleted-by-you.ts', 'deletedByYou'],
+ ['dropped.ts', 'noLongerGenerated'],
+ ['x.ts', 'other'],
+ ['y.ts', 'other']
]);
expect(plan.hasConflicts()).to.be.true;
- expect(counts(plan)).to.deep.equal({ added: 0, updated: 0, merged: 0, removed: 0, kept: 0 });
+ expect(counts(plan.files)).to.deep.equal({ added: 0, updated: 1, merged: 0, removed: 1, kept: 0 });
+ });
+
+ it('takes the stronger account of a path git reports twice, whichever comes first', () => {
+ for (const order of [
+ ['binary', 'contents'],
+ ['contents', 'binary']
+ ] as const) {
+ const plan = MergePlan.from(
+ inputs({
+ merge: merge({
+ stages: [stage('logo.png', 1), stage('logo.png', 2), stage('logo.png', 3)],
+ messages: order.map((kind) => conflict(kind, 'logo.png'))
+ })
+ })
+ );
+
+ expect(kinds(plan.conflicts), order.join(' then ')).to.deep.equal([['logo.png', 'binary']]);
+ }
+ });
+
+ it('leaves out a path git names that the merge leaves nowhere, such as the old name of a file the generator renamed and you deleted', () => {
+ const plan = renamedAway();
+
+ expect(kinds(plan.conflicts)).to.deep.equal([['new.ts', 'deletedByYou']]);
+ expect(plan.stages).to.deep.equal([stage('new.ts', 1), stage('new.ts', 3)]);
+ expect(counts(plan.files)).to.deep.equal({ added: 0, updated: 0, merged: 0, removed: 0, kept: 0 });
+ });
+
+ it('names a file the merge moved into a directory the generator renamed by its new path alone, and counts neither', () => {
+ const plan = movedWithDirectory();
+
+ expect(kinds(plan.conflicts)).to.deep.equal([['model/custom.ts', 'other']]);
+ expect(plan.stages).to.deep.equal([]);
+ expect(named(plan.files)).to.deep.equal({
+ added: [],
+ updated: ['model/book.ts'],
+ merged: [],
+ removed: ['models/book.ts'],
+ kept: []
+ });
});
it('counts a file you added, which the generator never produced, among the kept ones', () => {
@@ -131,4 +236,105 @@ describe('MergePlan.from', () => {
expect(plan.writes).to.deep.equal([]);
expect(plan.isEmpty()).to.be.true;
});
+
+ it('is not empty while a conflict is left, even one that writes nothing, so the run never reports nothing changed above it', () => {
+ const plan = MergePlan.from(
+ inputs({
+ merge: merge({
+ stages: [stage('dropped.ts', 1), stage('dropped.ts', 2)],
+ messages: [conflict('modifyDelete', 'dropped.ts')]
+ }),
+ basePaths: ['dropped.ts'].map(rel),
+ yours: [{ status: 'M', path: rel('dropped.ts'), blob: blob('dy') }],
+ generated: [{ status: 'D', path: rel('dropped.ts') }]
+ })
+ );
+
+ expect(kinds(plan.conflicts)).to.deep.equal([['dropped.ts', 'noLongerGenerated']]);
+ expect(plan.writes).to.deep.equal([]);
+ expect(plan.deletes).to.deep.equal([]);
+ expect(plan.isEmpty()).to.be.false;
+ });
+
+ it('writes the merge as git made it when the developer resolves, markers and all, with the CLI’s labels, and keeps every stage', () => {
+ const plan = conflicted();
+
+ expect(written(plan.writes)).to.have.deep.members([
+ ['other.ts', 'og'],
+ ['same.ts', `${YOURS_MARKER}\nsame mine\n=======\nsame theirs\n${GENERATED_MARKER}\n`],
+ ['added.ts', `${YOURS_MARKER}\nadded mine\n=======\nadded theirs\n${GENERATED_MARKER}\n`],
+ ['deleted-by-you.ts', 'bg']
+ ]);
+ expect(plan.deletes).to.deep.equal(['gone.ts'].map(rel));
+ expect(plan.hasConflicts()).to.be.true;
+ expect(plan.stages).to.deep.equal(CONFLICTED.merge.stages);
+ expect(named(plan.files)).to.deep.equal({
+ added: [],
+ updated: ['other.ts'],
+ merged: [],
+ removed: ['gone.ts'],
+ kept: []
+ });
+ });
+});
+
+describe('MergePlan.taking', () => {
+ it('settles every conflict the generator’s way when asked to, keeping an edited file it no longer produces', () => {
+ const plan = conflicted();
+
+ const taken = plan.taking('generated');
+
+ expect(written(taken.writes)).to.have.deep.members([
+ ['other.ts', 'og'],
+ ['same.ts', 'same theirs\n'],
+ ['added.ts', 'added theirs\n'],
+ ['logo.png', 'lg'],
+ ['deleted-by-you.ts', 'bg']
+ ]);
+ expect(taken.deletes).to.deep.equal(['gone.ts'].map(rel));
+ expect(taken.hasConflicts()).to.be.false;
+ expect(taken.stages).to.deep.equal([]);
+ expect(named(taken.files).updated).to.have.members(['other.ts', 'logo.png', 'deleted-by-you.ts']);
+ expect(named(taken.files).merged).to.deep.equal(['same.ts', 'added.ts']);
+ expect(named(taken.files).kept).to.deep.equal(['dropped.ts']);
+ expect(named(taken.files).removed).to.deep.equal(['gone.ts']);
+ expect(plan.hasConflicts()).to.be.true;
+ });
+
+ it('settles every conflict your way when asked to: your lines in each marked block, and a whole file left as it is on disk', () => {
+ const taken = conflicted().taking('yours');
+
+ expect(written(taken.writes)).to.have.deep.members([
+ ['other.ts', 'og'],
+ ['same.ts', 'same mine\n'],
+ ['added.ts', 'added mine\n']
+ ]);
+ expect(taken.deletes).to.deep.equal(['gone.ts'].map(rel));
+ expect(taken.hasConflicts()).to.be.false;
+ expect(named(taken.files).merged).to.deep.equal(['same.ts', 'added.ts']);
+ expect(named(taken.files).kept).to.have.members(['logo.png', 'dropped.ts']);
+ expect(named(taken.files).updated).to.deep.equal(['other.ts']);
+ expect(named(taken.files).removed).to.deep.equal(['gone.ts']);
+ });
+
+ it('leaves a conflict of another shape as git placed it, for either side', () => {
+ const plan = movedWithDirectory();
+
+ for (const side of SIDES) {
+ const taken = plan.taking(side);
+
+ expect(taken.writes, side).to.deep.equal(plan.writes);
+ expect(taken.deletes, side).to.deep.equal(plan.deletes);
+ expect(taken.hasConflicts(), side).to.be.false;
+ expect(taken.files, side).to.deep.equal(plan.files);
+ }
+ });
+
+ it('does not bring back a path the generator renamed away when its deletion is settled for the generator', () => {
+ const plan = renamedAway();
+
+ expect(written(plan.taking('generated').writes)).to.deep.equal([['new.ts', 'n']]);
+ expect(plan.taking('generated').deletes).to.deep.equal([]);
+ expect(written(plan.taking('yours').writes)).to.deep.equal([]);
+ });
});
From b2892d93d8531a6cd0caf493a4fa3de9f4c0844e Mon Sep 17 00:00:00 2001
From: Asad Ali <14asadali@gmail.com>
Date: Tue, 3 Nov 2026 11:13:22 +0500
Subject: [PATCH 07/28] feat(sdk): settle or refuse what the last run left
unresolved before 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
---
.ai/plans/custom-code-phases.md | 21 +-
src/actions/sdk/generate.ts | 93 ++++++---
src/infrastructure/git-service.ts | 40 +++-
src/prompts/sdk/generate.ts | 137 ++++++++++---
src/types/common/value-map.ts | 4 +
src/types/git-context.ts | 189 ++++++++++++++----
src/types/git/git-operation.ts | 39 ++++
src/types/sdk/conflict.ts | 22 +-
src/types/sdk/merge-plan.ts | 41 +++-
src/types/sdk/repository-state.ts | 58 ++++++
src/types/sdk/unresolved-file.ts | 38 ++++
test/actions/sdk/generate-destination.test.ts | 161 ++++++++++++++-
test/actions/sdk/generate-merge.test.ts | 26 +--
test/helpers/merge.ts | 32 +++
test/infrastructure/git-service.test.ts | 75 ++++++-
test/prompts/sdk/generate.test.ts | 79 ++++++--
test/types/git-context.test.ts | 165 +++++++++++++--
test/types/sdk/conflict.test.ts | 9 +
test/types/sdk/merge-plan.test.ts | 32 ++-
test/types/sdk/repository-state.test.ts | 47 +++++
test/types/sdk/unresolved-file.test.ts | 69 +++++++
21 files changed, 1213 insertions(+), 164 deletions(-)
create mode 100644 src/types/git/git-operation.ts
create mode 100644 src/types/sdk/repository-state.ts
create mode 100644 src/types/sdk/unresolved-file.ts
create mode 100644 test/types/sdk/repository-state.test.ts
create mode 100644 test/types/sdk/unresolved-file.test.ts
diff --git a/.ai/plans/custom-code-phases.md b/.ai/plans/custom-code-phases.md
index 276416770..5217791fa 100644
--- a/.ai/plans/custom-code-phases.md
+++ b/.ai/plans/custom-code-phases.md
@@ -122,7 +122,9 @@ index, `commitTree`, `resolveRef`, `treeOfCommit`, `updateRef`, each failing wit
a string, which the prompts take as `reason`; `GitVersion` total, with `none`, `isFound` and
`isSupported`; `CommitId`, `TreeId`; `GitContext` on the SDK directory: `hasGitDirectory` (a
`.git` of its own, read without git), `isRepository(git)` (as that git sees it; false, with no
-git run, without a `.git` or a git that can serve), `recordSnapshot`, with the pristine ref a constant of the module;
+git run, without a `.git` or a git that can serve), `recordSnapshot`, with the pristine ref a constant of the module
+(since Phase 4's refactor, the questions a run asks of a repository, the operation under way, the
+unmerged paths, the record and its snapshot, are answered together by `inspect` as a `RepositoryState`);
`SdkContext` as in phase 0; no run flag reaches a context, and `execute` combines the facts: the
version is read only with a `.git` of its own or `--git-init`, and the run records only with a
usable git; the `--git-init` flag; the prompts for the two warnings, the initialised message, the
@@ -181,7 +183,7 @@ Slices: 2.1, done, the record and the save that keeps `.git`: `GenerationRecord`
`.apimatic/generation.json` written by every recording (the snapshot's message names the
generation) and read before the question, "Found" only on the first record, the
snapshot-missing warning without a remote, init before the save again, the hidden-entries
-count, `GitContext.save`, `hasFilesBesidesGit`, `generationRecord` (none when there is no record, the file written by `recordContents`) and `hasSnapshot`, `SdkContext`
+count, `GitContext.save`, `hasFilesBesidesGit`, `generationRecord` (none when there is no record, the file written by `recordContents`) and `hasSnapshot` (folded into `inspect` with Phase 4's refactor), `SdkContext`
deleted. 2.2, done, the merge: items 2, 3, 7 and 8 and the writes and removals of item 5, the
question skipped, the `--force` replacement and its warning; `GitContext.merge` (both trees,
`merge-tree`, the diffs, the plan) and `write` (removals, then writes whose bytes differ);
@@ -311,6 +313,21 @@ sdk-repository-context "settling what the last run left unresolved", the "inspec
unmerged files and the operation in progress; git-service `unmergedStages`;
`unresolved-file.test.ts`; `repository-facts.test.ts` for the order of the facts.
+Slices: 4.1, done, what is in the way: `GitOperation` (a value, none-able) and
+`GitService.operationInProgress`, `unmergedStages`, and the block in the action before the record is
+read that refuses an operation in progress and lists what is unmerged as Action required; the
+`operationInProgress` and `conflictsUnresolved` prompts. 4.2, done, settling what was left:
+`UnresolvedFile.settledFor` answering a `Settlement`, `GitContext.settlementFor` as the plan over the
+index's two sides and the disk, landed by the same `write` as a merge and followed by
+`GitService.unstage`, the question (`unresolvedChoice`, backing out cancels) with `--force` answering
+for the generator, and the second look; the `resolveLater` and `conflictsNotSettled` prompts, and
+`conflictsResolved` over paths for this run's conflicts and the last run's alike. Refactored with
+Phase 3 afterwards: `GitContext.inspect` answers every question a run asks of the repository as one
+`RepositoryState`; every plan carries the bytes that reach the disk, line endings decided by `merge`
+and `settlementFor`, so `write(plan)` lands any plan; the prompts render what they are handed
+(`sdkMerged` given its counts, `conflictSteps` and `conflictStepsByHand` picked by the action).
+`repository-facts.test.ts` has `repository-state.test.ts` as its counterpart.
+
### Phase 5: a clone merges against a snapshot it fetches
Behaviour:
diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts
index f6e4ec8e0..476eaf130 100644
--- a/src/actions/sdk/generate.ts
+++ b/src/actions/sdk/generate.ts
@@ -2,7 +2,7 @@ import { SdkGenerationService } from '../../infrastructure/services/sdk-generati
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { ActionResult } from '../action-result.js';
import { withDirPath } from '../../infrastructure/tmp-extensions.js';
-import { ConflictChoice, SdkGeneratePrompts } from '../../prompts/sdk/generate.js';
+import { ConflictChoice, SdkGeneratePrompts, UnresolvedChoice } from '../../prompts/sdk/generate.js';
import { CommandMetadata } from '../../types/common/command-metadata.js';
import { TempContext } from '../../types/temp-context.js';
import { isAvailableLanguage, Language, Stability } from '../../types/sdk/generate.js';
@@ -10,7 +10,6 @@ import { ProjectContext } from '../../types/project-context.js';
import { GitContext } from '../../types/git-context.js';
import { GitVersion } from '../../types/git/git-version.js';
import { GitService } from '../../infrastructure/git-service.js';
-import { GenerationRecord } from '../../types/sdk/generation-record.js';
export class GenerateAction {
private readonly prompts: SdkGeneratePrompts = new SdkGeneratePrompts();
@@ -68,30 +67,70 @@ export class GenerateAction {
if (gitVersion.isFound() && !gitVersion.isSupported()) {
this.prompts.gitVersionNotSupported(gitVersion);
}
- const isRepository = await gitContext.isRepository(gitVersion);
- if (isRepository.isErr()) {
- this.prompts.repositoryNotInspected(sdk, isRepository.error);
+ const inspected = await gitContext.inspect(gitVersion, language, stability);
+ if (inspected.isErr()) {
+ this.prompts.repositoryNotInspected(sdk, inspected.error);
return ActionResult.failed();
}
- const generationRecord = isRepository.value ? await gitContext.generationRecord() : GenerationRecord.none();
- const baseCommitId = generationRecord.baseFor(language, stability);
- const hasSnapshot = await gitContext.hasSnapshot(baseCommitId);
- if (hasSnapshot.isErr()) {
- this.prompts.repositoryNotInspected(sdk, hasSnapshot.error);
+ const repository = inspected.value;
+ if (repository.operation.isUnderWay()) {
+ this.prompts.operationInProgress(sdk, repository.operation);
return ActionResult.failed();
}
- if (baseCommitId !== undefined && !hasSnapshot.value) {
+ if (repository.hasUnresolved()) {
+ if (!force && !canAsk) {
+ this.prompts.conflictsUnresolved(sdk, repository.unresolved);
+ return ActionResult.actionRequired();
+ }
+ const choice: UnresolvedChoice = force
+ ? 'generated'
+ : await this.prompts.unresolvedChoice(sdk, repository.unresolved);
+ if (choice === 'cancel') {
+ this.prompts.resolveLater();
+ return ActionResult.cancelled();
+ }
+ if (choice === 'resolve') {
+ this.prompts.resolveLater();
+ return ActionResult.actionRequired();
+ }
+ const settlement = await gitContext.settlementFor(choice, repository.unresolved);
+ if (settlement.isErr()) {
+ this.prompts.conflictsNotSettled(settlement.error);
+ return ActionResult.failed();
+ }
+ const written = await gitContext.write(settlement.value);
+ if (written.isErr()) {
+ this.prompts.sdkNotWritten(sdk, written.error);
+ return ActionResult.failed();
+ }
+ const marked = await this.gitService.unstage(sdk, repository.unresolved);
+ if (marked.isErr()) {
+ this.prompts.conflictsNotSettled(marked.error);
+ return ActionResult.failed();
+ }
+ this.prompts.conflictsResolved(sdk, choice, repository.unresolved);
+ // Once more, so a file the settling could not clear stops the run before anything is generated.
+ const again = await gitContext.inspect(gitVersion, language, stability);
+ if (again.isErr()) {
+ this.prompts.repositoryNotInspected(sdk, again.error);
+ return ActionResult.failed();
+ }
+ if (again.value.hasUnresolved()) {
+ this.prompts.conflictsUnresolved(sdk, again.value.unresolved);
+ return ActionResult.actionRequired();
+ }
+ }
+ if (repository.isSnapshotMissing()) {
this.prompts.snapshotMissing(sdk);
}
- // A base that is here is merged against; without one the SDK is written afresh.
- const mergeBaseCommitId = hasSnapshot.value ? baseCommitId : undefined;
- const shouldMakeRepository = gitVersion.isSupported() && gitInit && !isRepository.value;
- const shouldRecord = shouldMakeRepository || isRepository.value;
- const isRecordingFirstTime = isRepository.value && !generationRecord.isFound();
- const shouldReplaceOldRecord = force && generationRecord.isFound() && mergeBaseCommitId === undefined;
+ const mergeBase = repository.mergeBase();
+ const shouldMakeRepository = gitVersion.isSupported() && gitInit && !repository.isRepository();
+ const shouldRecord = shouldMakeRepository || repository.isRepository();
+ const isRecordingFirstTime = repository.isRepository() && !repository.isRecorded();
+ const shouldReplaceOldRecord = force && repository.isRecorded() && mergeBase === undefined;
// A merge writes only what changed, so it never asks; a save replaces everything, so it does.
- if (!force && mergeBaseCommitId === undefined && (await gitContext.hasFilesBesidesGit())) {
+ if (!force && mergeBase === undefined && (await gitContext.hasFilesBesidesGit())) {
if (!canAsk) {
this.prompts.cannotAskToOverwrite(sdk);
return ActionResult.failed();
@@ -132,8 +171,8 @@ export class GenerateAction {
return ActionResult.failed();
}
- if (mergeBaseCommitId !== undefined) {
- const mergePlan = await gitContext.merge(mergeBaseCommitId, generated.value);
+ if (mergeBase !== undefined) {
+ const mergePlan = await gitContext.merge(mergeBase, generated.value);
if (mergePlan.isErr()) {
this.prompts.customCodeFailed(mergePlan.error);
return ActionResult.failed();
@@ -153,12 +192,12 @@ export class GenerateAction {
return ActionResult.cancelled();
}
const landing = choice === 'asPlaced' ? plan : plan.taking(choice);
- const previous = await gitContext.keepPrevious(mergeBaseCommitId);
+ const previous = await gitContext.keepPrevious(mergeBase);
if (previous.isErr()) {
this.prompts.customCodeFailed(previous.error);
return ActionResult.failed();
}
- const written = await gitContext.write(landing, generated.value);
+ const written = await gitContext.write(landing);
if (written.isErr()) {
this.prompts.sdkNotWritten(sdk, written.error);
const restored = await gitContext.restore(previous.value, landing);
@@ -174,21 +213,23 @@ export class GenerateAction {
this.prompts.snapshotNotRecordedAfterMerge(recorded.error);
}
if (choice !== 'asPlaced') {
- this.prompts.conflictsResolved(sdk, choice, plan.conflicts);
+ this.prompts.conflictsResolved(sdk, choice, plan.conflictedPaths());
}
if (landing.isEmpty()) {
this.prompts.sdkUnchanged(sdk);
} else {
- this.prompts.sdkMerged(sdk, landing);
+ this.prompts.sdkMerged(sdk, landing.files, landing.conflicts.length);
}
if (!landing.hasConflicts()) {
return ActionResult.success();
}
- const staged = await gitContext.stageConflicts(landing);
+ const staged = await this.gitService.stageConflicts(sdk, landing.stages);
if (staged.isErr()) {
this.prompts.conflictsNotStaged(staged.error);
+ this.prompts.conflictStepsByHand(sdk, landing.markedPaths(), landing.unmarkedPaths());
+ } else {
+ this.prompts.conflictSteps(sdk, landing.markedPaths(), landing.unmarkedPaths());
}
- this.prompts.conflictSteps(sdk, landing, staged.isOk());
return ActionResult.actionRequired();
}
diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts
index d31ce0502..ff8844496 100644
--- a/src/infrastructure/git-service.ts
+++ b/src/infrastructure/git-service.ts
@@ -1,5 +1,6 @@
import { Buffer } from 'node:buffer';
import fs from 'node:fs';
+import path from 'node:path';
import { TextDecoder } from 'node:util';
import { execa, ExecaError } from 'execa';
import { err, ok, Result } from 'neverthrow';
@@ -9,8 +10,9 @@ import { FilePath } from '../types/file/filePath.js';
import { RelativeFilePath } from '../types/file/relativeFilePath.js';
import { BlobId } from '../types/git/blob-id.js';
import { CommitId } from '../types/git/commit-id.js';
+import { GitOperation } from '../types/git/git-operation.js';
import { GitVersion } from '../types/git/git-version.js';
-import { Change, MergeTreeOutput, parseMergeTree, StageEntry } from '../types/git/merge-tree.js';
+import { Change, MergeTreeOutput, parseMergeTree, parseStageEntry, StageEntry } from '../types/git/merge-tree.js';
import { TreeEntry } from '../types/git/tree-entry.js';
import { TreeId } from '../types/git/tree-id.js';
import { ValueMap } from '../types/common/value-map.js';
@@ -203,6 +205,42 @@ export class GitService {
return ran.map(() => undefined);
}
+ /** The operation the repository is in the middle of, such as a `git merge` with conflicts left to resolve; none when it is not. */
+ public async operationInProgress(root: DirectoryPath): Promise> {
+ const marks = GitOperation.marks();
+ const ran = await run(['rev-parse', ...marks.flatMap((mark) => ['--git-path', mark])], { cwd: root });
+ return ran.map(({ stdout }) => {
+ const paths = stdout.trim().split('\n');
+ const found = marks.find((_, index) => fs.existsSync(path.resolve(root.toString(), paths[index].trim())));
+ return found === undefined ? GitOperation.none() : GitOperation.markedBy(found);
+ });
+ }
+
+ /** The index entries git holds as unmerged: what a merge left and `git add` has not marked resolved. */
+ public async unmergedStages(root: DirectoryPath): Promise> {
+ const ran = await run(['ls-files', '-u', '-z'], { cwd: root });
+ if (ran.isErr()) {
+ return err(ran.error);
+ }
+ return readAll(
+ 'ls-files',
+ ran.value.stdout.split('\0').filter((entry) => entry !== ''),
+ parseStageEntry
+ );
+ }
+
+ /** Takes settled `paths` out of the unmerged state, back to HEAD or forgotten where there is no HEAD yet; the disk is not touched. */
+ public async unstage(root: DirectoryPath, paths: readonly RelativeFilePath[]): Promise> {
+ if (paths.length === 0) {
+ return ok(undefined);
+ }
+ const ran = await run(['reset', '-q', '--pathspec-from-file=-', '--pathspec-file-nul'], {
+ cwd: root,
+ input: paths.map(String).join('\0')
+ });
+ return ran.map(() => undefined);
+ }
+
/** How `to` differs from `from`, file by file; a moved file is a deletion and an addition. */
public diffTrees(
root: DirectoryPath,
diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts
index 9de565c1c..f6442aae3 100644
--- a/src/prompts/sdk/generate.ts
+++ b/src/prompts/sdk/generate.ts
@@ -7,24 +7,27 @@ import { noteWrapped, notZippedForUpload, withSpinner } from '../prompt.js';
import { ServiceError } from '../../infrastructure/service-error.js';
import { AVAILABLE_LANGUAGES, Language, languageLabel, UPCOMING_LANGUAGES } from '../../types/sdk/generate.js';
import { GitVersion } from '../../types/git/git-version.js';
+import { GitOperation } from '../../types/git/git-operation.js';
import { CommitId } from '../../types/git/commit-id.js';
import { RelativeFilePath } from '../../types/file/relativeFilePath.js';
import { Side } from '../../types/sdk/conflict-markers.js';
-import { Conflict, ConflictKind } from '../../types/sdk/conflict.js';
-import { MergePlan } from '../../types/sdk/merge-plan.js';
+import { Conflict, ConflictKind, MergeFiles } from '../../types/sdk/conflict.js';
import { WriteProblem } from '../../types/sdk/write-problem.js';
import { listedInProse, plural } from '../../utils/string-utils.js';
/** What the developer chose for the conflicts: a side to settle them for, the files as git placed them to resolve by hand, or to stop. */
export type ConflictChoice = Side | 'asPlaced' | 'cancel';
+/** How the developer answered about the last run's unresolved files: finish resolving themselves, let one side win, or back out. */
+export type UnresolvedChoice = 'resolve' | Side | 'cancel';
+
const names = (languages: readonly Language[]) => languages.map(languageLabel).join(', ');
/** The words that change between one conflicted file and several. */
const forms = (count: number) =>
count === 1
- ? { them: 'it', theyAre: 'it is', files: 'file', need: 'needs' }
- : { them: 'them', theyAre: 'they are', files: 'files', need: 'need' };
+ ? { them: 'it', theyAre: 'it is', files: 'file', need: 'needs', are: 'is' }
+ : { them: 'them', theyAre: 'they are', files: 'files', need: 'need', are: 'are' };
const CONFLICT_LABELS: Record = {
bothChanged: 'changed by you and by the generator',
@@ -60,7 +63,33 @@ const conflictLines = (sdk: DirectoryPath, conflicts: readonly Conflict[]) =>
const listed = (sdk: DirectoryPath, paths: readonly RelativeFilePath[]) =>
paths.map((path) => ` ${f.relativePath(path.in(sdk))}`);
-const pathsOf = (conflicts: readonly Conflict[]) => conflicts.map((conflict) => conflict.path);
+const stillUnresolved = (paths: readonly RelativeFilePath[]) =>
+ `${plural(paths.length, 'file')} from the last run ${forms(paths.length).are} still unresolved`;
+
+/** The files to resolve by hand, those with markers and those without apart, each group only when it has files. */
+const byHand = (sdk: DirectoryPath, marked: readonly RelativeFilePath[], unmarked: readonly RelativeFilePath[]) => [
+ ...(marked.length === 0
+ ? []
+ : ['With markers: keep what is right, remove the markers, and stage each file.', ...listed(sdk, marked)]),
+ ...(unmarked.length === 0
+ ? []
+ : [
+ 'Without markers: decide whether each file stays or goes (delete it again, or keep it as your own), and stage that.',
+ ...listed(sdk, unmarked)
+ ])
+];
+
+/** The "Next steps" note: how to resolve, the files by hand, then a rerun. */
+const stepsNote = (
+ resolve: string,
+ sdk: DirectoryPath,
+ marked: readonly RelativeFilePath[],
+ unmarked: readonly RelativeFilePath[]
+) =>
+ noteWrapped(
+ [resolve, ...byHand(sdk, marked, unmarked).map((line) => ` ${line}`), '2. Rerun the command.'].join('\n'),
+ 'Next steps'
+ );
export class SdkGeneratePrompts {
/**
@@ -253,9 +282,9 @@ export class SdkGeneratePrompts {
log.info(`Nothing was written; ${f.relativePath(sdk)} is as it was.`);
}
- /** The files the run settled for `side`, named before the summary counts them. */
- public conflictsResolved(sdk: DirectoryPath, side: Side, conflicts: readonly Conflict[]) {
- log.info([RESOLVED_HEADINGS[side], ...listed(sdk, pathsOf(conflicts))].join('\n'));
+ /** The files the run settled for `side`, this run's conflicts or the last run's, named before the summary counts them. */
+ public conflictsResolved(sdk: DirectoryPath, side: Side, paths: readonly RelativeFilePath[]) {
+ log.info([RESOLVED_HEADINGS[side], ...listed(sdk, paths)].join('\n'));
}
/** The file the write stopped at, and why. */
@@ -287,13 +316,12 @@ export class SdkGeneratePrompts {
}
/** The counts of what the generator did, and of what is left in conflict, and by name the files of yours it merged into or left alone. */
- public sdkMerged(sdk: DirectoryPath, plan: MergePlan) {
- const { files } = plan;
+ public sdkMerged(sdk: DirectoryPath, files: MergeFiles, inConflict: number) {
const counted = [
...COUNTED_FILES.filter((kind) => files[kind].length > 0).map(
(kind) => `${plural(files[kind].length, 'file')} ${kind}`
),
- ...(plan.hasConflicts() ? [`${plural(plan.conflicts.length, 'file')} in conflict`] : [])
+ ...(inConflict > 0 ? [`${plural(inConflict, 'file')} in conflict`] : [])
];
const named = NAMED_FILES.filter(([kind]) => files[kind].length > 0).flatMap(([kind, heading]) => [
heading,
@@ -310,26 +338,73 @@ export class SdkGeneratePrompts {
);
}
- /** How to finish a merge left to the developer: a merge tool only where git holds the files as unmerged, the files with markers and without apart, then a rerun. */
- public conflictSteps(sdk: DirectoryPath, plan: MergePlan, staged: boolean) {
- const { them } = forms(plan.conflicts.length);
- const tool = staged ? `in a merge tool, such as ${f.var('git mergetool')} or your editor's merge view, or ` : '';
- const byHand = [
- {
- conflicts: plan.conflicts.filter((conflict) => conflict.leavesMarkers()),
- what: 'With markers: keep what is right, remove the markers, and stage each file.'
- },
- {
- conflicts: plan.conflicts.filter((conflict) => !conflict.leavesMarkers()),
- what: 'Without markers: decide whether each file stays or goes (delete it again, or keep it as your own), and stage that.'
- }
- ].flatMap(({ conflicts, what }) => (conflicts.length === 0 ? [] : [what, ...listed(sdk, pathsOf(conflicts))]));
- const steps = [
- `1. Resolve ${them} ${tool}by hand:`,
- ...byHand.map((line) => ` ${line}`),
- '2. Rerun the command.'
- ];
- noteWrapped(steps.join('\n'), 'Next steps');
+ /** How to finish a merge left to the developer, once git holds the files as unmerged: a merge tool or by hand, the files with markers and without apart, then a rerun. */
+ public conflictSteps(sdk: DirectoryPath, marked: readonly RelativeFilePath[], unmarked: readonly RelativeFilePath[]) {
+ const { them } = forms(marked.length + unmarked.length);
+ const tool = `in a merge tool, such as ${f.var('git mergetool')} or your editor's merge view, or by hand:`;
+ stepsNote(`1. Resolve ${them} ${tool}`, sdk, marked, unmarked);
+ }
+
+ /** As `conflictSteps`, for files git does not hold as unmerged, where no merge tool will find them. */
+ public conflictStepsByHand(
+ sdk: DirectoryPath,
+ marked: readonly RelativeFilePath[],
+ unmarked: readonly RelativeFilePath[]
+ ) {
+ const { them } = forms(marked.length + unmarked.length);
+ stepsNote(`1. Resolve ${them} by hand:`, sdk, marked, unmarked);
+ }
+
+ /** The repository is busy with an operation of the developer's own, which the run would get in the way of. */
+ public operationInProgress(sdk: DirectoryPath, operation: GitOperation) {
+ const abort = f.cmdAlt('git', operation.toString(), '--abort');
+ log.error(
+ `The repository at ${f.relativePath(sdk)} is in the middle of a ${operation}. ` +
+ `Finish it, or abort it with ${abort}, then rerun.`
+ );
+ }
+
+ /** The files the last run left unresolved, for a run that cannot settle them: nothing is done until they are staged. */
+ public conflictsUnresolved(sdk: DirectoryPath, paths: readonly RelativeFilePath[]) {
+ const { them } = forms(paths.length);
+ log.warn([`${stillUnresolved(paths)}. Resolve and stage ${them}, then rerun:`, ...listed(sdk, paths)].join('\n'));
+ }
+
+ /** The files still unresolved, then the three ways the run can go on; backing out is cancelling. */
+ public async unresolvedChoice(sdk: DirectoryPath, paths: readonly RelativeFilePath[]): Promise {
+ const { them } = forms(paths.length);
+ log.warn([`${stillUnresolved(paths)}:`, ...listed(sdk, paths)].join('\n'));
+ const choice = await select({
+ message: 'What should happen?',
+ options: [
+ {
+ value: 'resolve',
+ label: `I'll finish resolving ${them}`,
+ hint: `nothing is done now; resolve and stage ${them}, then rerun`
+ },
+ {
+ value: 'generated',
+ label: 'Resolve remaining conflicts using generator changes',
+ hint: "where markers remain, the generator's lines win; what you already resolved stays"
+ },
+ {
+ value: 'yours',
+ label: 'Resolve remaining conflicts using my changes',
+ hint: 'where markers remain, your lines win; what you already resolved stays'
+ }
+ ]
+ });
+ return isCancel(choice) ? 'cancel' : choice;
+ }
+
+ /** Nothing was done about the unresolved files: the developer will finish them, or backed out. */
+ public resolveLater() {
+ log.info('Nothing was done. Rerun once the files are resolved and staged.');
+ }
+
+ /** The unresolved files could not be settled, so the run stops before generating. */
+ public conflictsNotSettled(reason: string) {
+ log.error(`The conflicts could not be settled: ${reason}`);
}
/** The SDK is merged and on disk; the record still names the last snapshot, which the next run merges against again. */
diff --git a/src/types/common/value-map.ts b/src/types/common/value-map.ts
index a545f6d82..b3fbf18c4 100644
--- a/src/types/common/value-map.ts
+++ b/src/types/common/value-map.ts
@@ -51,4 +51,8 @@ export class ValueSet {
public has(key: K): boolean {
return this.members.has(key);
}
+
+ public values(): K[] {
+ return this.members.values();
+ }
}
diff --git a/src/types/git-context.ts b/src/types/git-context.ts
index 881f61720..df453bcd8 100644
--- a/src/types/git-context.ts
+++ b/src/types/git-context.ts
@@ -3,20 +3,23 @@ import { FileService } from '../infrastructure/file-service.js';
import { GitService } from '../infrastructure/git-service.js';
import { canonical } from '../infrastructure/tmp-extensions.js';
import { errorMessage } from '../utils/error-utils.js';
-import { ValueMap } from './common/value-map.js';
+import { ValueMap, ValueSet } from './common/value-map.js';
import { DirectoryPath } from './file/directoryPath.js';
import { FileName } from './file/fileName.js';
import { FilePath } from './file/filePath.js';
import { RelativeFilePath } from './file/relativeFilePath.js';
import { CommitId } from './git/commit-id.js';
import { GitVersion } from './git/git-version.js';
-import { SIDE_STAGE } from './git/merge-tree.js';
+import { SIDE_STAGE, STAGE, StageEntry } from './git/merge-tree.js';
import { TreeEntry } from './git/tree-entry.js';
import { TreeId } from './git/tree-id.js';
+import { Side } from './sdk/conflict-markers.js';
+import { lineEndingFor } from './sdk/file-content.js';
import { Language, Stability } from './sdk/generate.js';
import { GenerationRecord, recordContents } from './sdk/generation-record.js';
-import { lineEndingFor } from './sdk/file-content.js';
import { MergePlan } from './sdk/merge-plan.js';
+import { RepositoryState } from './sdk/repository-state.js';
+import { UnresolvedFile } from './sdk/unresolved-file.js';
import { WriteProblem } from './sdk/write-problem.js';
/** The ref holding each generation as the server returned it, chained, off every branch of the SDK directory's own repository. */
@@ -33,13 +36,19 @@ const RECORD_DIRECTORY = '.apimatic';
const RECORD_FILE = new FileName('generation.json');
const RECORD_PATH = `${RECORD_DIRECTORY}/${RECORD_FILE.toString()}`;
-/** The SDK directory under git: what the disk and git answer about it, one question per method, and what a run does to it. */
+/**
+ * The SDK directory under git: what the disk and git say about it, what a run plans for it, how a
+ * plan lands on it, and the snapshots it keeps. Every plan carries the bytes that reach the disk,
+ * line endings decided, so landing one is the same whatever made it.
+ */
export class GitContext {
private readonly gitService = new GitService();
private readonly fileService = new FileService();
constructor(private readonly directory: DirectoryPath) {}
+ // --- What the disk and git say ---------------------------------------------------------------
+
/** Whether the directory has a `.git` of its own, read from disk so no git is needed to know whether git will be. */
public async hasGitDirectory(): Promise {
return (
@@ -77,21 +86,48 @@ export class GitContext {
return GenerationRecord.parse(await this.fileService.getContents(file));
}
- /** Whether the repository holds the snapshot a record names; false for none, with no git run. */
- public async hasSnapshot(commit: CommitId | undefined): Promise> {
- return commit === undefined ? ok(false) : this.gitService.hasCommit(this.directory, commit);
+ /**
+ * What git says about the directory for a generation, read once and in order: whether it is a
+ * repository of its own as `git` sees it, the operation it is in the middle of, the paths it holds
+ * as unmerged, and the snapshot the record names for `language` at `stability`, with whether it is
+ * here. A directory that is not a repository answers with nothing, running no git past the root.
+ */
+ public async inspect(
+ git: GitVersion,
+ language: Language,
+ stability: Stability
+ ): Promise> {
+ const isRepository = await this.isRepository(git);
+ if (isRepository.isErr()) {
+ return err(isRepository.error);
+ }
+ if (!isRepository.value) {
+ return ok(RepositoryState.notARepository());
+ }
+ const operation = await this.gitService.operationInProgress(this.directory);
+ if (operation.isErr()) {
+ return err(operation.error);
+ }
+ const unmerged = await this.gitService.unmergedStages(this.directory);
+ if (unmerged.isErr()) {
+ return err(unmerged.error);
+ }
+ const record = await this.generationRecord();
+ const base = record.baseFor(language, stability);
+ const baseHeld = base === undefined ? ok(false) : await this.gitService.hasCommit(this.directory, base);
+ if (baseHeld.isErr()) {
+ return err(baseHeld.error);
+ }
+ const unresolved = new ValueSet(unmerged.value.map((entry) => entry.path)).values();
+ return ok(RepositoryState.of(operation.value, unresolved, record.isFound(), base, baseHeld.value));
}
- /** Replaces whatever the directory holds with the generated SDK, except its `.git`. */
- public async save(generated: DirectoryPath): Promise {
- await this.fileService.cleanDirectoryExcluding(this.directory, [GIT_DIRECTORY]);
- await this.fileService.copyDirectoryContents(generated, this.directory);
- return this.directory;
- }
+ // --- What a run plans --------------------------------------------------------------------------
/**
- * The fresh generation at `generated` merged over the directory as it is, against `base`, by git: what
- * to write, with its bytes, and remove, what conflicts, and what happened to every other file. Nothing is written.
+ * The fresh generation at `generated` merged over the directory as it is, against `base`, by git:
+ * what to write, each file with the bytes that reach the disk, and remove, what conflicts, and what
+ * happened to every other file. Nothing is written.
*/
public async merge(base: CommitId, generated: DirectoryPath): Promise> {
const generatedTree = await this.gitService.treeOf(this.directory, generated);
@@ -156,28 +192,86 @@ export class GitContext {
if (contents.isErr()) {
return err(contents.error);
}
- return ok(
- MergePlan.from({
- merge: merged.value,
- // The CLI's own record is not an edit of yours.
- yours: yourChanges.value.filter((change) => change.path.toString() !== RECORD_PATH),
- generated: generatedChanges.value,
- writes: changed.map((change, index) => ({ path: change.path, content: contents.value[index] })),
- deletes: applied.value.flatMap((change) => (change.status === 'D' ? [change.path] : [])),
- basePaths,
- generatedVersions: new ValueMap(
- generatorStages.map((entry, index): [RelativeFilePath, Uint8Array] => [
+ try {
+ // Each file ends its lines as it does on disk, else as the generator's own does.
+ const writes = await Promise.all(
+ changed.map(async (change, index) => ({
+ path: change.path,
+ content: await ending(
+ this.fileService,
+ contents.value[index],
+ change.path.in(this.directory),
+ change.path.in(generated)
+ )
+ }))
+ );
+ const versions = await Promise.all(
+ generatorStages.map(
+ async (entry, index): Promise<[RelativeFilePath, Uint8Array]> => [
entry.path,
- generatedVersions.value[index]
- ])
+ await ending(this.fileService, generatedVersions.value[index], entry.path.in(generated))
+ ]
)
- })
+ );
+ return ok(
+ MergePlan.from({
+ merge: merged.value,
+ // The CLI's own record is not an edit of yours.
+ yours: yourChanges.value.filter((change) => change.path.toString() !== RECORD_PATH),
+ generated: generatedChanges.value,
+ writes,
+ deletes: applied.value.flatMap((change) => (change.status === 'D' ? [change.path] : [])),
+ basePaths,
+ generatedVersions: new ValueMap(versions)
+ })
+ );
+ } catch (error) {
+ return err(errorMessage(error));
+ }
+ }
+
+ /**
+ * The last run's unresolved `paths` settled for `side`, as a plan: from the disk and the two sides
+ * git kept in the index, markers settled in place and whole files taken as the side says, each
+ * with the line ending the disk has. Nothing is written.
+ */
+ public async settlementFor(side: Side, paths: readonly RelativeFilePath[]): Promise> {
+ const stages = await this.gitService.unmergedStages(this.directory);
+ if (stages.isErr()) {
+ return err(stages.error);
+ }
+ const kept = stages.value.filter((entry) => entry.stage !== STAGE.base);
+ const contents = await this.gitService.readBlobs(
+ this.directory,
+ kept.map((entry) => entry.oid)
);
+ if (contents.isErr()) {
+ return err(contents.error);
+ }
+ const yours = versionsAt(kept, contents.value, SIDE_STAGE.yours);
+ const generated = versionsAt(kept, contents.value, SIDE_STAGE.generated);
+ try {
+ const settlements = await Promise.all(
+ paths.map(async (path) => {
+ const disk = await firstOf(this.fileService, path.in(this.directory));
+ const version = generated.get(path);
+ const theirs = version === undefined ? undefined : lineEndingFor(version, disk);
+ return new UnresolvedFile(path, disk, yours.get(path), theirs).settledFor(side);
+ })
+ );
+ return ok(MergePlan.empty().settled(settlements));
+ } catch (error) {
+ return err(errorMessage(error));
+ }
}
- /** Marks the plan's conflicted paths in the repository's own index as a merge would leave them, so `git status` and a merge tool see them. */
- public async stageConflicts(plan: MergePlan): Promise