From bd7030213968b55b8ac6554982a09b36bd60b8ca Mon Sep 17 00:00:00 2001 From: Kenneth Domingo Date: Sun, 11 Oct 2026 18:01:41 +0800 Subject: [PATCH 1/3] docs: gitbutler setup (#4) --- .claude/skills/gitbutler/SKILL.md | 212 ++++++ .../skills/gitbutler/references/concepts.md | 327 +++++++++ .../skills/gitbutler/references/examples.md | 475 +++++++++++++ .../skills/gitbutler/references/reference.md | 636 ++++++++++++++++++ CLAUDE.md | 54 ++ 5 files changed, 1704 insertions(+) create mode 100644 .claude/skills/gitbutler/SKILL.md create mode 100644 .claude/skills/gitbutler/references/concepts.md create mode 100644 .claude/skills/gitbutler/references/examples.md create mode 100644 .claude/skills/gitbutler/references/reference.md create mode 100644 CLAUDE.md diff --git a/.claude/skills/gitbutler/SKILL.md b/.claude/skills/gitbutler/SKILL.md new file mode 100644 index 0000000..9c4101f --- /dev/null +++ b/.claude/skills/gitbutler/SKILL.md @@ -0,0 +1,212 @@ +--- +name: but +version: 0.22.3 +description: "Commit, push, branch, and manage version control with GitButler. Use for commits, selective dirty-file or hunk commits, branches, diffs, PRs, history edits, squashes, amends, undo, merge, apply, and unapply. For selected dirty files or hunks, inspect with `but diff`; use compact `but status` for commit order, branch/stack placement, or conflict overview; use `but status -fv` when file/hunk IDs or per-commit file details matter. Replaces git write commands." +author: GitButler Team +--- + +# GitButler CLI Skill + +Use GitButler CLI (`but`) as the default version-control interface. + +## Start Here + +Choose the narrowest first command by task; avoid ritual status checks: + +```bash +# Selected dirty files/hunks: +but diff + +# Commit order, branch/stack placement, conflict overview: +but status + +# File/hunk IDs, per-commit files, amend/split details: +but status -fv + +# Details for one known branch or commit: +but show +``` + +Do not run plain `but status` and then `but status -fv` unless the compact output lacks file/hunk details needed for the task. + +For "commit just/only/specific changes on a new branch", use the fast path: + +```bash +but diff +but commit -b -m "" +``` + +`but commit -b ...` creates the branch when it does not exist and prints the created commit. Do not run a separate `but branch new`, status command, or verification diff unless the task needs workspace information that the result does not provide. + +## IDs + +The first token on each `but diff` / `but status` line is that line's ID. When a command needs an ID, copy it exactly from the current output; never hardcode or invent one. IDs may be a single character when unambiguous, and their lifetimes differ by entity: + +- Changes and sources are **positional, space-separated** IDs (`but commit -b feat -m "msg" qs:5 uo`). A hunk ID is written `:` (e.g. `qs:5`, copied from `but diff`) — the part after the colon is the hunk's ID, **not** a line range (`qs:16-40` is invalid). Do not invent flags like `--changes` / `--hunk` / `--ids`, pass a line range, or comma-separate IDs — `nk,pn` is parsed as one ID and fails. +- `but diff` is the exception: it accepts at most **one** target. Bare `but diff` shows all uncommitted files; inspect committed files or other entities one target at a time — never `but diff `. +- A committed file is `:` (e.g. `uyr:n`, shown under each commit). `zz` means the uncommitted area. +- Commit IDs are stable change IDs that survive history edits (`amend`, `squash`, `move`, `uncommit`, `reword`). Commits without a change ID (e.g. upstream-only) lead with a sha prefix instead, and `#N`-suffixed refs disambiguate duplicates — both go stale after history edits, and a stale sha can silently resolve to the wrong commit. The `(sha …)` on verbose commit lines is informational — do not pass it to commands. +- Branch short IDs are snapshot-local selectors. Use full branch names for branch-targeting mutations; short IDs are safe only for immediate read-only inspection. +- File/hunk IDs copied from one diff read generally remain usable across chained commits. If one stops resolving, re-read `but diff` and retry. + +**Chaining:** mutation output is concise by default, so you may chain mutations with `&&` off one inspection read. Do not chain branch short IDs through mutations; use full branch names. Add `--status-after` only when the next step needs workspace IDs or details that the mutation result does not provide. Chained `but commit` calls stack in the order written — the first is oldest, each later one goes on top. History edits may run in sequence when every commit ref involved is a change-ID ref; run them one at a time with `--status-after` when a ref is sha-based or `#N`-suffixed, or when the next command needs freshly issued IDs. Chaining `but uncommit && but diff` is safe because bare `diff` needs no ID from the uncommit output. + +## Non-Negotiable Rules + +1. Use `but` for all write operations. Never run `git add`, `git commit`, `git push`, `git checkout`, `git merge`, `git rebase`, `git stash`, or `git cherry-pick`. If the user says a `git` write command, translate it to `but` and run that. Exception: a worktree-local Git commit when `but commit` reports that linked worktrees are unsupported. Never run `but setup` from a linked worktree. +2. Mutation commands print their result without appending workspace status. Add `--status-after` only when the next step needs resulting workspace IDs or details; otherwise trust the mutation result and do not run a verification status/diff. +3. Branches marked `(merged upstream)` have landed; run `but pull` to remove them, or start new work on another branch. `push` and mutations (`commit`, `amend`, `squash`, `uncommit`, `reword`, `move`) refuse landed branches and commits, `absorb` skips them with a notice, and `commit` skips them when picking a default target. +4. In non-interactive CLI workflows, do not narrate progress between routine commands. Execute the needed `but` commands and give a concise final summary. +5. Prefer this skill and `references/reference.md` over exploratory help calls. Use ` --help` when required syntax is missing or a command fails; use top-level help only when you genuinely need to discover an undocumented command. + +## Command Patterns + +- Commit: `but commit -b -m "" ` — `-b ` creates the branch if it does not exist +- Commit everything uncommitted: `but commit -b -m ""` (omit the IDs) +- Several commits from one diff: chain `but commit` calls with `&&` (commits stack oldest-first) +- Commit at a specific history position: `--above ` or `--below ` instead of `-b` +- Only one targeting flag (`-b` / `--above` / `--below`) per command. Targeting is **required** when more than one **stack** is applied; without it `but commit` fails with "Unclear where to commit. Found more than one stack". Several branches stacked together count as one stack — an untargeted commit then silently lands on the stack's top branch, so pass `-b` whenever the branch matters. +- Always pass `-m ""` (or `--no-message`) to `but commit`, and to `but squash` whenever its sources are commits or branches unless the target is `zz` — those compose a new message, and without a flag an editor opens and blocks. Squash sources that are uncommitted or committed files reuse the target's message and need no flag; squashing into `zz` rejects message flags outright. +- Amend: `but amend -t ` — a branch target resolves to its newest commit +- Uncommit: `but uncommit ` (whole commit), `but uncommit ` (all commits and remove the branch), or `but uncommit :` (one committed file); multiple committed-file sources in one call must come from one commit +- Insert empty commit: `but commit --empty -b -m ""` +- Squash commits: `but squash [...] -t -m ""` +- Squash a whole branch into one commit: `but squash -m ""` (no `-t`) +- Uncommit and remove a branch: `but uncommit ` +- Reorder commits: `but move --below ` (`--above` for the other direction; **commit IDs**, not branch names) +- Reorder a block: `but move --below ` or `--above ` (both anchors accept multiple space-separated sources) +- Move commit to branch top: `but move -b ` +- Stack branches: `but move --above ` (**use full branch names**) +- Tear off a branch: `but move --unstack` +- Discard: `but discard [...]` — accepts branches, commits, committed files, uncommitted files/hunks, or `zz` for all uncommitted changes +- Push: `but push ` — pushes the selected branch and its ancestors; to update a stack, select its top branch once and never loop. Bare `but push` pushes all unpushed work when run non-interactively — one push per stack (its topmost unpushed branch, ancestors included), so output has one entry per stack, not per branch. It exits non-zero if any stack failed; stacks that already pushed stay pushed, and rerunning after fixing the failure is safe (up-to-date stacks are skipped) +- Pull (update workspace from the target): `but pull` — the output reports the result; `but pull --check` previews without updating when a preview is actually needed +- Create PR: `but pr new [-m "Title..."] [-F pr_message.txt] [-t] [--draft]` — auto-pushes first; do not run `but push` before it + +## Task Recipes + +### Update workspace from main + +For "get latest from main", "update/sync this workspace", "rebase onto main", or "pull main": + +1. `but pull` — one command; no preflight needed. Its output reports the resulting state, it refuses safely when uncommitted changes conflict, and `but undo` reverts it. +2. If commits come back conflicted, resolve them oldest-first following the printed instructions: `but resolve `, edit the files, then `but resolve finish`. Its result gives the current ID of the next conflict. Add `--status-after` to the finish you expect to clear the last conflict only when the task needs the complete resulting workspace. When it says no conflicted commits remain, stop; do not run a verification status. Finishing a lower commit rebases the ones above it, so always work bottom-up. + +`but pull --check` answers "would this conflict?" without updating. Do not use it as a routine +preflight; use it when the user asks for a preview, repository policy requires one, or other agents' +branches may move. + +Rebasing applied branches onto the latest target IS `but pull` — never `move`, `config target`, `unapply`, or raw `git pull`/`git rebase`. The base shown in status is the last FETCHED state: when `git log` shows `main` (local or remote) ahead of it, that is exactly the update `but pull` fetches and applies — the target setting is not stale and repointing it is never the fix. Pull carries uncommitted changes along, and its output reports the resulting state. If it refuses because uncommitted changes conflict, park them: `but commit -b -m "wip" `, pull again, then `but uncommit` the parked commit (there is no stash; do not hand-revert files). + +### Commit selected files or hunks + +1. `but diff` — shows file and hunk IDs for uncommitted changes. Do not run plain `but status` first. +2. Use file IDs when whole files belong in the commit; use hunk IDs (`:`) when only part of a file belongs. Omit IDs you don't want committed. +3. `but commit -b -m "" ` — the branch is created if it does not exist, so no prior `but branch new` is needed. +4. When the task requires knowing which changes remain, add `--status-after`; otherwise the created-commit result is sufficient. + +Edge case: if wanted and unwanted edits are in the same diff hunk, GitButler cannot split that hunk by ID. Only when the task requires keeping part of that hunk uncommitted, temporarily edit the working tree to isolate the wanted lines, commit those IDs, then restore the leftover lines so they remain uncommitted. + +### Amend into existing commit + +1. `but status -fv` (or `but show `) — locate file/hunk IDs and target commit IDs. +2. `but amend -t ` — one command per target commit. For several target commits, chain the amends with `&&` when every target is a change-ID ref; otherwise run them one at a time with `--status-after` to get fresh refs. + +### Split an existing commit + +Use this when an existing commit should be replaced by selected smaller commits. + +1. `but status -fv` when you need the source commit, branch name, or placement anchor. +2. `but uncommit && but diff` in one shell call exposes the commit's changes and prints the resulting file and hunk IDs. +3. Pick replacement contents from that dirty diff, not from the old committed diff. +4. Determine the requested chronological order from the user's wording, then create the replacement commits oldest-first by chaining `but commit` calls. Do not reverse that order to match `but status`, which displays commits newest-first. `-b ` puts each new commit at the TOP of that branch — if the split commit had commits above it, the replacements now sit above those preserved commits. +5. **If commits from that branch must stay ABOVE the replacements, put the preserved block back on top instead of fighting anchors.** Do not anchor with `--above `/`--below ` (sha/`#N` anchors go stale as each insert rewrites history). Move the block together so its internal order stays intact: append `&& but move [...] -b ` to the commit chain. Change-ID refs from step 1 stay valid; wait for fresh output first if any preserved ref is sha-based or `#N`-suffixed. +6. Leave unwanted changes uncommitted. Replacements created oldest-first appear newest-first in status — that is correct; do not reorder them. Add `--status-after` to the final mutation when you need to inspect the resulting order. + +### Reorder commits + +`but status` displays commits newest/top first, while task specs often list history oldest to newest — translate before moving. + +1. `but status` once to get commit IDs (use `-fv` only if you also need file details). +2. `but move --below ` places source immediately below target in `but status` (older in oldest-to-newest history). `--above` places it immediately above (newer). `but move -b ` moves it to branch top/newest. +3. For an adjacent block, run ONE move, anchored either way: `but move --below ` or `but move --above `. Pick an anchor outside the block; source order does not matter and the block keeps its internal order. Add `--status-after` when you need to inspect the resulting order; do not move the anchor or block members again. +4. For other reorders, make the smallest set of moves. + +### Squash commits + +1. `but status` for commit IDs and order. +2. Name the sources positionally and the result/target commit with `-t`: `but squash [...] -t -m ""`. +3. To collapse an entire branch into one commit, pass just the branch and no `-t`: `but squash -m ""`. +4. Multiple independent groups may run in sequence off one status read (targets keep their change-ID refs); prefer newer/top groups first. Take fresh refs only when a ref is sha-based or `#N`-suffixed. +5. Add `--status-after` to the final squash when you need to inspect the resulting history; do not re-verify with a separate status. + +### Stack existing branches + +To make one existing branch depend on another: `but move --above ` (use full branch names; commit reordering uses commit IDs). To unstack: `but move --unstack`. + +**DO NOT** stack via `uncommit` + `branch delete` + `branch new -a` (git branch names persist after delete and it loses work), and do not use `but undo` to unstack. + +### Create or manage pull requests + +`but pr new ` pushes the selected branch and its ancestors, then creates the PR in one step — no prior `but push`. Provide `-F pr_message.txt`, `-t`, or `-m` with real newlines (zsh/bash: `-m $'Title\n\nBody'`) so no editor opens. If forge auth is missing, run `but config forge auth`. + +If you do create a PR for a stacked branch, use `but pr` — not `gh pr create` (only `but pr` sets PR bases and stack metadata; `gh pr create` breaks that). To publish a whole stack: `but pr new -t`. Manage with `but pr auto-merge|set-draft|set-ready `; selectors can be a branch name, current branch/stack CLI ID, or numeric review ID. See `references/reference.md` for details. + +### Dependency conflict with another branch + +Changes that build on another branch's commits cannot land on an independent branch. `but commit` and `but amend` fail atomically ("Cannot commit: N changes could not be applied"), naming the branch and commit each rejected change depends on — nothing is committed and no `-b` branch is created. + +When there is a single dependency branch, the error's Hint gives the exact recovery command: `but move --above ` to stack an existing branch on its dependency, or `but branch new --anchor ` when the target branch didn't exist yet. Run it, then retry the original command. When the error names dependencies without a Hint (several dependency branches, or the dependency is on the target branch itself), run `but status -fv` to see where the dependent commits live before choosing a placement. + +If that recovery command fails, do NOT try `uncommit`, `squash`, or `undo` as a workaround — re-run `but status -fv` to confirm both branches exist and are applied, then retry with exact branch names. + +### Resolve conflicted commits (after pull, move, or reorder) + +**NEVER use `git add`, `git commit`, `git checkout --theirs/--ours`, or any git write command during resolution.** Only `but resolve` commands plus direct file edits. + +Conflicts do not interrupt operations in GitButler: a rebase always completes, and commits that conflicted are marked `{conflicted}` in `but status`. Find them from the warning that history-editing commands (`move`, `discard`, …) print, from the `but pull` summary, or from `but status`. Resolve them one conflict at a time, without entering any mode. Work through one branch at a time, oldest commit first — the loop is: + +1. `but resolve conflicts ` — shows the conflicts of that branch's oldest conflicted commit, numbered per file. Each conflict has three sections: `ours` (the new base the commit was rebased onto), `base` (common ancestor), and `theirs` (the commit's own version). Without a branch it picks the first conflicted branch and tells you which; the output also says when other conflicted commits exist. +2. Apply one resolution per command: + - Merged/mixed content (the common case — combine the intent of both sides): pipe it to `but resolve apply :` via stdin (heredoc) or pass `--file `. The content replaces the whole conflicted region; never include conflict markers. + - Take a side entirely: `but resolve apply : --ours` or `--theirs` (bare `` applies the side to all conflicts in that file). + - Delegate one conflict to the configured AI model: `but resolve apply : --ai` — prefer your own merged content when you have the context. + - `apply` targets the same default commit as `conflicts`; when more than one branch is conflicted, pin it with `--commit `. +3. Go back to step 1 until it reports no conflicts. Commit ids are not stable here — every apply rewrites the commit — but branch names are, so address everything by branch and never bookkeep commit ids. Partial progress is fine: a commit with unresolved conflicts left stays `{conflicted}` with exactly those conflicts. + +A wrong resolution is reverted with `but undo`. + +`but resolve --ai` resolves a whole commit with the configured AI model in one shot, and `but resolve --ai` without a commit resolves every conflicted commit, oldest first — acceptable as a fallback, but you usually have better context than that model does, so prefer resolving the conflicts yourself with `apply`. + +**Use edit mode only when you must run code against the resolution** (build/tests at that commit): + +1. `but resolve ` — enters resolution mode and prints the conflict regions. +2. **Edit the files** to remove every conflict marker — `<<<<<<<`, `|||||||` (the common-ancestor section), `=======` and `>>>>>>>` — and keep the correct content. Do NOT skip this; do NOT use `but amend` on conflicted commits. +3. `but resolve finish` reports leftover markers, surviving uncommitted changes, every remaining conflicted commit, and the exact current `but resolve ` command. Add `--status-after` to the finish you expect to clear the last conflict only when the task needs the complete resulting workspace. When it says no conflicted commits remain, stop; do not run a verification status. Cancel instead with `but resolve cancel`. +4. Repeat for remaining conflicted commits, oldest first — finishing a lower commit rebases the ones above it. + +### Conflicts in uncommitted files + +`but status` marks uncommitted files with unresolved merge conflicts `{conflicted}`; they are excluded from committable changes and outside `but resolve` mode. Edit the file to the wanted contents (or delete it), then `but resolve ...` to mark it resolved with that state; it then shows as an ordinary uncommitted change. + +## Git-to-But Map + +| git | but | +|---|---| +| `git status` | `but status` for branch/stack/commit overview; `but status -fv` for file/hunk details; `but diff` for selected dirty changes | +| `git add` + `git commit` | `but commit -b -m ... ` | +| `git checkout -b` + commit | `but commit -b -m ... ` | +| `git push` | `but push ` | +| `git rebase -i` | `but move`, `but squash`, `but reword` | +| `git rebase --onto` | `but move --above ` | +| `git checkout -- ` / `git restore` | `but discard ` | +| `git cherry-pick` | `but pick` | +| `gh pr create` | `but pr new -m "Title..."` | + +## Notes + +- Read-only git inspection (`git log`, `git blame`, `git show --stat`) is allowed. +- If `but` prints an `AGENT ACTION REQUIRED` skill warning, run the suggested command once, then reload/use the GitButler skill. If it repeats, report it instead of retrying. +- For command syntax and flags: `references/reference.md` +- For workspace model: `references/concepts.md` +- For workflow examples: `references/examples.md` diff --git a/.claude/skills/gitbutler/references/concepts.md b/.claude/skills/gitbutler/references/concepts.md new file mode 100644 index 0000000..e665bf6 --- /dev/null +++ b/.claude/skills/gitbutler/references/concepts.md @@ -0,0 +1,327 @@ +# GitButler CLI Key Concepts + +Deep dive into GitButler's conceptual model and philosophy. + +## The Workspace Model + +### Traditional Git: Serial Branching + +``` +main ──┬── feature-a (checkout here, work, commit, checkout back) + └── feature-b (checkout here, work, commit, checkout back) +``` + +- Work on ONE branch at a time +- Switch contexts with `git checkout` +- Changes are isolated by branch + +### GitButler: Parallel Stacks + +``` +workspace (gitbutler/workspace) + ├─ feature-a (applied, merged into workspace) + ├─ feature-b (applied, merged into workspace) + └─ feature-c (unapplied, not in workspace) +``` + +- Work on MULTIPLE branches simultaneously +- No context switching - all applied branches merged in working directory +- Changes are ASSIGNED to branches, not isolated by checkout + +### Key Implications + +1. **No `git checkout`**: You don't switch between branches. All applied branches exist simultaneously in your workspace. + +2. **The `gitbutler/workspace` branch**: A merge commit containing all applied stacks. Don't interact with it directly - use `but` commands. + +3. **Applied vs Unapplied**: Control which branches are active: + - Applied branches: In your working directory + - Unapplied branches: Exist but not active + - Use `but apply`/`but unapply` to control + +## CLI IDs: Short Identifiers + +Every object gets a short, human-readable CLI ID shown in `but status`. IDs are generated per-session and are unique across all entity types (no two objects share an ID) — always read them from `but status`. + +``` +Commits: 1, kyn, mpq#0 (short change-ID prefix when the commit has one, sha prefix otherwise; + a #N suffix disambiguates commits sharing a change ID) +Branches: fe, bu, ui (unique 2–3 char substring of the branch name, e.g. "fe" from "feature-x"; + falls back to auto-generated ID if no unique substring exists) +Files: g, qs, uo (derived from the file path, long enough to be unique) +Hunks: g:5, uo:d (:; the hunk part is derived from the hunk's content) +Committed files: kyn:n (:, shown under each commit in `but status -fv`) +Stacks: m0, n0 (auto-generated, 2–3 chars) +``` + +**Why?** Git commit SHAs are long (40 chars). CLI IDs are short, variable-length, and unique within your current workspace context. Commits, files, and hunks may use a single character when that is unambiguous. + +**Reading status output:** the first token on each line is that line's ID. Verbose commit lines append an informational `(sha …)` after the timestamp — it changes on every amend; do not pass it to commands. + +**Stability:** Branch short IDs identify branches only within the workspace snapshot that produced them; branch names remain stable across unrelated workspace mutations. File/hunk IDs copied from the current output generally remain usable across ordinary commits, so you can reference several in a row, including across chained `but commit` calls. If an ID stops resolving, re-read the diff and continue. Commit IDs are change-ID prefixes when the commit has a change ID and sha prefixes otherwise. Change-ID refs survive history edits (`amend`, `squash`, `move`, `uncommit`, `reword`); sha refs and `#N`-suffixed refs do not — a stale sha can silently resolve to the wrong commit. History edits may run in sequence off one status read when every ref involved is a change-ID ref; otherwise run them one at a time with `--status-after` to get the next ref. + +**Usage:** Pass these IDs as arguments to commands: + +```bash +but commit -b -m "message" # Commit selected changes to a branch +but amend -t # Amend file(s) or hunk(s) into commit +but squash -t -m "message" # Squash commits +``` + +IDs are positional and space-separated. `but help cli-ids` documents every ID kind in detail. + +**Linked worktrees** (experimental, only with the `worktreeManipulation` feature flag on): each +active linked worktree gets its own ID and is drawn in `but status` as a lane — a braced +`{}` heading (the worktree name when its `HEAD` is detached) nested above the commit the +worktree rests on — another worktree's commit included, lanes nest recursively — or standing on +its own below the stacks when it rests outside the workspace. +The lane lists that checkout's uncommitted files and the commits the worktree owns; in `--json` +they appear in a top-level `worktrees` array. The worktree ID on the heading names its whole +uncommitted area the way `zz` names the main worktree's, and `:` scopes a +filename to that checkout — `zz:` keeps meaning the main worktree. A filename dirty in +several checkouts at once is ambiguous; the error suggests the scoped forms. A worktree file or +heading ID — like `zz` for the main checkout — works as a `but commit` change and a `but amend` +source: the change lands on the target and leaves that worktree's uncommitted area. Without a +target flag, worktree changes commit to the tip of the worktree's own branch; an explicit target +commit or branch does not have to be the worktree's own. One operation reads from one checkout +at a time — a selection mixing checkouts is refused. A worktree is also a target: `but commit`, +`but move`, and `but pick` with `-b ` or `--below ` +place the commit on the tip of the branch the worktree has checked out (`--above` is refused — +that is its uncommitted area). A worktree's own commits carry ordinary commit IDs: `reword`, `move`, +`squash`, and `pick` accept them, and the worktree's branch and checkout follow the rewrite. + +## Parallel vs Stacked Branches + +### Parallel Branches (Independent Work) + +Create with `but branch new `: + +``` +main ──┬── api-endpoint (independent) + └── ui-update (independent) +``` + +Use when: + +- Tasks don't depend on each other +- Can be merged independently +- No shared code between them + +Example: Adding a new API endpoint and updating button styles are independent. + +### Stacked Branches (Dependent Work) + +**To stack an existing branch** on top of another: `but move --above `. + +**To create a new stacked branch** from scratch: `but branch new -a ` — only use this when the child branch doesn't exist yet. + +``` +main ── authentication ── user-profile ── settings-page + (base) (stacked) (stacked) +``` + +Use when: + +- Feature B needs code from Feature A +- Building incrementally on previous work +- Creating a series of related changes + +Example: User profile page needs authentication to be implemented first. + +**Stacking two existing branches:** If both branches already exist and you need to make one depend on the other, use top-level `move`: +```bash +but move feature/frontend --above feature/backend +# Now frontend is stacked on top of backend — both in the same stack +``` + +To tear off a branch from a stack: + +```bash +but move feature/frontend --unstack +``` + +**Dependency tracking:** GitButler automatically tracks which changes depend on which commits. A dependent change can only be committed to the stack that contains the commits it depends on. + +## The Editing Model + +History editing is expressed as *sources* and a *target*. Sources are positional CLI IDs; the target +is a flag. `zz` is a special ID meaning "the uncommitted area". + +`but squash` carries most of the model — what it does depends on the kinds you combine: + +| Sources | Target (`-t`) | Operation | Example | +| ---------------- | ------------- | --------------------------------- | ----------------------------- | +| Commit(s) | Commit | Squash commits together | `but squash mm -t nn -m "…"` | +| Branch | Commit | Squash a branch into a commit | `but squash -t nn -m "…"` | +| Commit(s) | Branch | Squash into the branch's newest | `but squash mm -t -m "…"` | +| Branch | *(none)* | Squash the branch into one commit | `but squash -m "…"` | +| Uncommitted file | Commit | Amend the change into a commit | `but squash a1 -t nn` | +| `zz` | Commit | Amend everything into a commit | `but squash zz -t nn` | +| Commit | `zz` | Uncommit the commit | `but squash mm -t zz` | +| Branch | `zz` | Uncommit and remove the branch | `but squash -t zz` | +| Committed file | Commit | Move the file to another commit | `but squash nn:a -t mm` | + +**Message flags:** commits or branches compose a NEW message unless the target is `zz`, so without +`-m` they open an editor and block — always pass one. The remaining rows reuse the target's message +and need no flag, and `-t zz` rejects message flags outright. + +The two amend rows overlap with `but amend` — prefer `but amend -t nn a1`, which does only that and +takes the same IDs. Reach for `squash` when the sources are commits, branches, or committed files, +which `amend` does not accept. + +The other editing commands are narrower entry points on the same model: + +- `but amend -t ` — amend uncommitted files/hunks into a known commit +- `but uncommit ` — move committed work back to uncommitted; + branches are removed, and committed files in one call must come from one commit +- `but move --above|--below|--branch|--unstack` — relocate commits, committed files, or a + branch; this is the command with position control +- `but discard ` — drop work instead of relocating it + +## Dependency Tracking + +GitButler tracks dependencies between changes automatically. + +### How It Works + +``` +Commit C1: Added function foo() +Commit C2: Added function bar() +Uncommitted: Call to foo() in new code +``` + +The uncommitted change **depends on** C1 (because it calls `foo()`). + +**Implications:** + +1. Can't commit this change to a stack that doesn't contain C1 +2. When amending it into history, it belongs in C1 (or a commit after C1) +3. If you try to move the change, GitButler prevents invalid operations + +### Why This Matters + +Prevents you from creating broken states: + +- Can't move dependent code away from its dependencies +- Can't commit changes to the wrong stack +- Ensures each branch remains independently functional + +## Empty Commits as Placeholders + +You can create empty commits: + +```bash +but commit --empty --below nn -m "TODO: Add error handling" +but commit --empty --above nn -m "TODO: Add error handling" +``` + +**Use cases:** + +1. **Mark future work:** Create empty commit as placeholder for changes you'll make +2. **Organize history:** Add semantic markers in commit history + +Example workflow: + +```bash +but commit --empty --below rr -m "TODO: Add error handling" +# Later, amend the error handling changes into the placeholder +but amend -t +``` + +## Operation History (Oplog) + +Every operation in GitButler is recorded in the oplog (operation log). + +### What Gets Recorded + +- Branch creation/deletion +- Commits +- Squash/amend/move/uncommit/discard operations +- Push/pull operations + +### Using Oplog + +```bash +but oplog # View history +but undo # Undo last operation +but redo # Redo last undone operation +but oplog list --since +but oplog list --snapshot +but oplog snapshot -m "known good" +but oplog restore # Restore to specific point +``` + +Think of it as "git reflog" but for all GitButler operations, not just branch movements. + +**Safety net:** Made a mistake? `but undo` it. Experimented and want to go back? `but oplog restore` to earlier snapshot. + +## Applied vs Unapplied Branches + +Branches can be in two states: + +### Applied Branches + +- Active in your workspace +- Merged into `gitbutler/workspace` +- Changes visible in working directory +- Can make changes and commit + +### Unapplied Branches + +- Exist but not active +- Not in working directory +- Can't make changes (must apply first) +- Useful for temporarily setting aside work + +### Controlling State + +```bash +but apply # Make branch active +but unapply # Make branch inactive +``` + +**Use cases:** + +- Unapply branches causing conflicts +- Focus on subset of work (unapply others) +- Temporarily set aside work without deleting + +## Conflict Resolution Mode + +When `but pull` causes conflicts, affected commits are marked as conflicted. + +### Resolution Workflow + +1. **Identify:** the `but pull` summary lists each conflicted commit's ID, oldest first (`but status` also shows them) +2. **Enter mode:** `but resolve ` — it prints the conflict regions with line numbers. With several conflicted commits, resolve the oldest first: finishing a lower commit rebases the ones above it +3. **Fix conflicts:** Edit files, remove conflict markers (`but resolve status` re-lists what remains when several files are conflicted) +4. **Finalize:** `but resolve finish` or `but resolve cancel` — finish reports leftover markers and the surviving uncommitted changes, so no follow-up check is needed + +### During Resolution + +- You're in a special mode focused on that commit +- Other GitButler operations are limited +- `but status` shows you're in resolution mode +- Must finish or cancel before continuing normal work + +## Read-Only Git Commands + +Git commands that don't modify state are safe to use: + +**Safe (read-only):** + +- `git log` - View history +- `git diff` - See changes (but prefer `but diff` — it supports CLI IDs) +- `git show` - View commits +- `git blame` - See line history +- `git reflog` - View reference log + +**Don't use in a GitButler workspace:** + +- `git status` - Misleading: shows merged workspace state, not individual stacks; missing CLI IDs that agents need +- `git commit` - Commits to the workspace merge commit, not your branch +- `git checkout` - Breaks workspace model +- `git rebase` - Conflicts with GitButler's management +- `git merge` - Use `but land` instead + +**Rule of thumb:** If it reads, it's fine. If it writes, use `but` instead. diff --git a/.claude/skills/gitbutler/references/examples.md b/.claude/skills/gitbutler/references/examples.md new file mode 100644 index 0000000..aec4937 --- /dev/null +++ b/.claude/skills/gitbutler/references/examples.md @@ -0,0 +1,475 @@ +# GitButler CLI Workflow Examples + +Real-world examples of common workflows. + +**Note on CLI IDs:** Examples below use full branch names for branch-targeting mutations. Illustrative IDs like `nn` and `a1` keep other commands readable; in practice, **always read actual IDs from `but status -fv`** because they are generated for the current workspace snapshot. Commit IDs are short change-ID prefixes that stay stable across history edits (e.g., `kyn`; commits without a change ID fall back to a sha prefix), and file/hunk/stack IDs are auto-generated (e.g., `r`, `r:c`, `h0`). All IDs are unique across entity types within one snapshot. + +## Example 1: Starting Independent Parallel Work + +**Scenario:** Need to work on two independent features: a new API endpoint and UI styling updates. + +```bash +# 1. Check current state +but status -fv + +# 2. Create two independent (parallel) branches +but branch new api-endpoint +but branch new ui-styling + +# 3. Make changes to multiple files +# (edit api/users.js and components/Button.svelte) + +# 4. Check what's uncommitted +but status -fv + +# 5. Commit specific files by passing their CLI IDs (recommended for agents) +# Use full branch names plus file IDs from but status -fv output +# Multiple IDs are space-separated positional arguments. +but commit -b api-endpoint -m "Add user details endpoint" +but commit -b ui-styling -m "Update button hover styles" + +# Follow-up fix that belongs in a commit you just made? Amend it in. +# Add --status-after only when the next step needs resulting workspace IDs or details. +# but amend -t + +# 6. Create pull requests (auto-pushes the branches; -m sets the PR title so no editor opens) +but pr new api-endpoint -m "Add user details endpoint" +but pr new ui-styling -m "Update button hover styles" +``` + +**Why parallel branches?** The API endpoint and UI styling are independent - neither depends on the other. They can be reviewed and merged separately. + +## Example 2: Building Stacked Features + +**Scenario:** Need to add authentication, then build a user profile page that requires auth. + +```bash +# 1. Check current state and update +but pull +but status -fv + +# 2. Create base branch for authentication +but branch new add-authentication + +# 3. Implement auth and commit +# (edit auth/login.js, auth/middleware.js) +but status -fv +but commit -b add-authentication -m "Add JWT authentication" + +# 4. Create stacked branch anchored on authentication +but branch new user-profile -a add-authentication + +# 5. Implement profile page (depends on auth) +# (edit pages/profile.js) +but status -fv +but commit -b user-profile -m "Add user profile page" + +# 6. Create stacked pull requests from the top branch once (auto-pushes its ancestors) +but pr new user-profile -t +``` + +**Result:** Two PRs where user-profile targets add-authentication, with GitButler stack information in the PR descriptions. + +## Example 3: Amending Fixes Into Existing Commits + +**Scenario:** Made a small typo fix that should be part of an existing commit, not a new commit. + +```bash +# 1. Check current commits and uncommitted changes +but status -fv + +# Output shows: +# Branch: feature-x (bu) +# Commits: +# nn: Implement feature logic +# mm: Add feature tests +# Uncommitted: +# a1: fix-typo.js + +# 2. Decide which commit the fix belongs to +# (the typo is in code introduced by nn, so it belongs in nn) + +# 3. Amend the file into that commit +but amend -t nn a1 # Amend just this file + get updated status +``` + +**Why amend?** Keeps history clean. Small fixes belong in the commits they fix, not as separate "fix typo" commits. You know what you changed and why — pick the target commit yourself. + +## Example 4: Reorganizing Commit History + +### Scenario A: Squashing Commits + +**Situation:** Made 5 small WIP commits, want to combine into one logical commit. + +```bash +# Before (newest first): +# rr: More tweaks +# pp: Fix another thing +# nn: Fix tests +# mm: Adjust logic +# kk: Initial implementation + +# Squash all commits in the branch into one +but squash feature -m "Implement feature" + +# Or squash specific commits into a target commit +but squash rr pp nn mm -t kk -m "Implement feature" +``` + +### Scenario B: Moving Files Between Commits + +**Situation:** A file was committed in the wrong commit, need to move it. + +```bash +# 1. See which files are in which commits +but status -fv + +# Output shows: +# nn: Add API layer +# nn:a1 api.js +# nn:a2 utils.js +# mm: Add config +# mm:c1 config.js + +# 2. Move utils.js from nn to mm +but squash nn:a2 -t mm -u # Committed file nn:a2 (utils.js) → commit mm, keep mm's message +``` + +### Scenario C: Moving Commit to Different Branch + +**Situation:** Committed to wrong branch, need to move commit. + +```bash +# 1. Check current state +but status -fv + +# Output: +# Branch: feature-a (bu) +# nn: This should be in feature-b! +# mm: Correct commit + +# 2. Create or identify target branch +but branch new feature-b # Creates branch bv + +# 3. Move the commit +but move nn -b feature-b # Move nn to top of feature-b +``` + +## Example 5: Stacking Existing Branches + +**Scenario:** Two independent branches exist, but one now depends on the other. Stack them. + +```bash +# 1. Check current state — two independent branches in separate stacks +but status -fv + +# Output: +# Stack 1: feature/backend (bu) — 2 commits +# Stack 2: feature/frontend (bv) — 1 commit + +# 2. Frontend now depends on backend API — stack frontend on backend +# IMPORTANT: Use full branch names; short IDs belong to the previous snapshot +but move feature/frontend --above feature/backend + +# Result: Both branches are now in the same stack: +# Stack 1: feature/backend → feature/frontend (stacked) + +# 3. Continue working — commits go to the right branch +but status -fv +but commit -b feature/backend -m "Add caching layer" +but commit -b feature/frontend -m "Add dialog component" +``` + +**Key point:** branch stack moves use full branch names like `feature/frontend`. Commit reordering still uses commit IDs. + +## Example 6: Conflict Resolution + +**Scenario:** After `but pull`, conflicts appear in a commit. + +```bash +# 1. Pull updates +but pull + +# Output: +# Summary +# ──────── +# feature-x - conflicted +# nn Add validation + +# 2. Enter resolution mode using the commit ID from the pull output +but resolve nn + +# Output: +# Checking out conflicted commit nn +# Conflicted files remaining: +# ✗ api/users.js +# 12│<<<<<<< New base: ... +# ...conflict regions with line numbers... + +# 3. Edit each conflicted file to resolve +# IMPORTANT: You MUST edit the files — do NOT just run `but resolve finish` +# NEVER use `git add`, `git checkout --theirs/--ours`, or any git write command — just edit the files directly with the Edit tool, then `but resolve finish` +# (edit to remove every marker — <<<<<<< ||||||| ======= >>>>>>> — and keep correct content; +# with several conflicted files, `but resolve status` re-lists what remains) + +# 4. Finalize +but resolve finish + +# Output: +# ✓ Conflict resolution finalized successfully! +# No conflict markers remain in the resolved files. +# Workspace restored; uncommitted changes intact: ... +# No follow-up status or marker scan needed — finish already reports both. +``` + +## Example 7: Complete Feature Development Workflow + +**Scenario:** Building a complete feature from start to finish. + +```bash +# 1. Update to latest +but pull + +# 2. Create branch for feature +but branch new user-dashboard + +# 3. Make initial changes +# (create dashboard.js, add routes) + +# 4. Check status and gather file IDs +but status -fv + +# 5. First commit +but commit -b user-dashboard -m "Add dashboard route and basic layout" + +# 6. Continue iterating +# (add widgets, styling) +but commit -b user-dashboard -m "Add dashboard widgets" +but commit -b user-dashboard -m "Style dashboard components" + +# 7. Make small fix +# (fix typo in widget) +but amend -t a1 # Amend fix into the commit it belongs to + +# 8. Clean up if needed +but squash user-dashboard -m "Add user dashboard" # Combine all commits (optional) + +# 9. Create pull request (auto-pushes the branch) +but pr new user-dashboard -m "Add user dashboard" + +# Output: +# Created PR #123: https://github.com/org/repo/pull/123 + +# 10. After PR is merged, update +but pull +``` + +## Example 8: Working with Applied/Unapplied Branches + +**Scenario:** Have 3 branches, but two are causing conflicts. Temporarily unapply them. + +```bash +# 1. Check active branches +but status -fv + +# Output: +# Applied branches: +# bu: feature-a +# bv: feature-b +# bw: feature-c + +# 2. Conflicts between feature-b and feature-c +# Unapply them temporarily +but unapply feature-b +but unapply feature-c + +# 3. Focus on feature-a +# (make changes, commit) +but commit -b feature-a -m "Complete feature-a" + +# 4. Create PR for feature-a (auto-pushes) +but pr new feature-a -m "Complete feature-a" + +# 5. Reapply other branches +but apply feature-b +but apply feature-c + +# 6. Deal with their conflicts now +but resolve ... +``` + +## Example 9: Fixing History Before Pushing + +**Scenario:** Made several commits, realized you need to reword messages and reorder. + +```bash +# 1. Current state +but status -fv + +# Output (newest first): +# Branch: feature-x (bu) +# rr: final commit +# pp: WIP +# nn: Fix stuff +# mm: Another fix +# kk: Initial + +# 2. Reword commit messages — commit refs are change-ID based and stay +# valid across rewords and other history edits +but reword pp -m "Add validation logic" +but reword nn -m "Fix edge case in parser" +but reword mm -m "Update error messages" + +# 3. Move rr to be earlier +but move rr --below nn # Place rr directly below nn + +# 4. Squash similar commits +but squash mm -t nn -u # Combine error handling commits; -u keeps nn's message, drops mm's + +# Output (newest first): +# Branch: feature-x (bu) +# pp: Add validation logic +# nn: Fix edge case in parser +# rr: final commit +# kk: Initial + +# 5. Push clean history +but push feature-x +``` + +## Example 10: Daily Development Workflow + +**Typical day working with GitButler:** + +```bash +# Morning: Start day +but pull # Get latest from team + +# Start new task +but branch new fix-auth-bug # Create branch for today's work + +# Work and commit iteratively +# (make changes) +but status -fv # Check changes +but commit -b fix-auth-bug -m "Identify auth bug source" +# (make more changes) +but commit -b fix-auth-bug -m "Fix token expiration handling" +# (small fix to existing code) +but amend -t a1 # Amend fix into the commit it belongs to + +# Mid-day: Start urgent fix on different branch +but branch new hotfix-login # Parallel branch for urgent work +# (make fix) +but commit -b hotfix-login -m "Fix login redirect loop" +but pr new hotfix-login -m "Fix login redirect loop" # Push and create PR immediately + +# Back to original work +# (continue working on fix-auth-bug) +but commit -b fix-auth-bug -m "Add tests for token handling" + +# End of day: Clean up and create PR +but squash fix-auth-bug -m "Fix auth bug" # Combine into clean history +but pr new fix-auth-bug -m "Fix auth bug" # Push and create PR + +# After PR review: Make requested changes +# (make changes based on feedback) +but amend -t # Amend each fix into the commit it belongs to +but push fix-auth-bug # Push updated history +``` + +## Example 11: Recovering from Mistakes + +**Scenario:** Made changes you didn't mean to, need to undo. + +### Undo Last Operation + +```bash +# Made a mistake +but squash feature -m "..." # Oops! Didn't mean to squash + +# Undo it +but undo # Reverts the squash +``` + +### Restore to Earlier Point + +```bash +# View operation history +but oplog + +# Output (snapshot refs are git SHAs, not CLI IDs): +# 9c1f2ab 2026-01-02 [SQUASH] Squashed commits +# f8a3733 2026-01-02 [COMMIT] Created commit +# 4b70e19 2026-01-02 [AMEND] Amended commit +# 1d5c806 2026-01-02 [BRANCH] Created branch + +# Restore to before the squash, using the SHA from the output +but oplog restore f8a3733 +``` + +### Discard Uncommitted Changes + +```bash +# Changed a file but want to discard +but status -fv + +# Output: +# Uncommitted: +# a1: bad-changes.js + +# Discard it +but discard a1 +``` + +## Tips and Tricks + +### Quick Status Check + +```bash +but status -fv # File-centric view for quick overview +``` + +### Preview Before Doing + +```bash +but push my-feature --dry-run # See what would be pushed +``` + +### Multiple Commits From One Diff + +File/hunk IDs copied from the original output generally remain usable across +commits. Chain `but commit` calls to split a dirty diff into several commits in +one go: + +```bash +but diff # read the file/hunk IDs once + +but commit -b my-branch -m "Add parser" qs:5 qs:2 \ + && but commit -b my-branch -m "Add tests" uo:d +``` + +The commits stack in the order you write them, so `Add parser` ends up below (older +than) `Add tests`. Chain these commit commands when each references uncommitted IDs +and a full branch name. If an ID stops resolving, re-read the diff and continue. +Mutation output is concise by default. Add `--status-after` only when the next +step needs workspace IDs or details that the mutation result does not provide. +History edits — `amend`, `squash`, `move`, `uncommit`, `reword` — may also run in +sequence off one status read when every commit ref involved is a change-ID ref; +those stay stable across the edits. Run them one at a time when a ref is sha-based +or `#N`-suffixed, or when the next command needs freshly issued IDs, and add +`--status-after` to get them. + + +### Auto-completion + +```bash +eval "$(but completions zsh)" # Add to ~/.zshrc +eval "$(but completions bash)" # Add to ~/.bashrc +``` + +### Viewing History + +```bash +but show bu # Show all commits in branch +git log bu # Traditional git log (read-only, still works) +``` diff --git a/.claude/skills/gitbutler/references/reference.md b/.claude/skills/gitbutler/references/reference.md new file mode 100644 index 0000000..d8e4629 --- /dev/null +++ b/.claude/skills/gitbutler/references/reference.md @@ -0,0 +1,636 @@ +# GitButler CLI Command Reference + +Agent-focused reference for useful `but` commands. + +## Contents + +- [Inspection](#inspection-understanding-state) - `status`, `show`, `diff`, `open` +- [Branching](#branching) - `branch new`, `apply`, `unapply`, `branch delete`, `pick` +- [Committing](#committing) - `commit` +- [Editing History](#editing-history) - `squash`, `amend`, `move`, `uncommit`, `reword`, `discard` +- [Conflict Resolution](#conflict-resolution) - `resolve` +- [Remote Operations](#remote-operations) - `push`, `pull`, `pr`, `land` +- [Workspace Maintenance](#workspace-maintenance) - `clean`, `worktree` +- [History & Undo](#history--undo) - `undo`, `oplog` +- [Setup & Configuration](#setup--configuration) - `setup`, `teardown`, `config`, `update`, `skill` +- [Selected Options](#selected-options) + +## Inspection (Understanding State) + +### `but status` + +Overview of branch, stack, commit, and workspace state. Use this when you need existing branch/stack/commit/conflict context. For selected dirty-file or hunk commits, start with `but diff` instead. + +```bash +but status # Compact overview with branch, stack, commit IDs, and commit subjects +but status -fv # File-centric view with full commit details and file IDs +but status --verbose # Detailed information +but status --upstream # Show upstream relationship +``` + +Shows: + +- Applied/unapplied branches in workspace +- Uncommitted and assigned changes +- Commits on each stack +- CLI IDs to use in other commands + +The first token on each line is that line's ID. A `` is a full branch name or short ID from the current workspace snapshot. Short IDs may be reassigned as that context changes; branch names remain stable across unrelated workspace mutations. Agents should use full branch names for branch-targeting mutations. Commit lines lead with the commit's change ID (stable across history edits); commits without a change ID lead with a sha prefix, which goes stale after history edits. Verbose output appends an informational `(sha …)` after the timestamp — do not pass the sha to commands. + +### `but show ` + +Details about a commit or branch. + +```bash +but show # Show details +but show --verbose # Show with full messages and file details +``` + +### `but diff [target]` + +Display diff for file, branch, stack, or commit. + +```bash +but diff # Diff for entire workspace; best first command for selective dirty commits +but diff # Diff for specific file +but diff # Diff for all changes in branch +but diff # Diff for specific commit +``` + +`but diff` accepts at most one target. Bare `but diff` shows every uncommitted file; +inspect committed files or other entities one target at a time. Unlike `commit`, +`amend`, and `discard`, it does not accept several positional IDs. + +**Hunk IDs:** For uncommitted changes, `but diff` shows each hunk with an ID (e.g., `qs:5`, `uo:d`). Pass these IDs to `but commit` for fine-grained, hunk-level commits. + +For the full CLI ID model, `but help cli-ids` documents every ID kind and its stability. + +### `but open [target]` + +Open the GitButler app at a branch or commit, or print the link with `--print`. + +```bash +but open # The workspace +but open # With that branch selected +but open # With that commit selected +but open --print # Print the link instead of opening it +``` + +Commits are addressed by change ID where they have one, so the link keeps +working after the commit is amended or rebased. + +## Branching + +### `but branch` + +List all branches (default when no subcommand). + +```bash +but branch # List branches +but branch list [filter] # Filter branches by name (case-insensitive substring) +but branch list --no-ahead # Skip commits-ahead calculation (faster) +but branch list --no-check # Skip clean-merge check (faster) +but branch list -r # Show only remote branches +but branch list -l # Show only local branches +but branch list -a # Show all branches (not just active + 20 most recent) +but branch list --empty # Include empty branches +but branch list --review # Fetch and display review information +``` + +To rename an applied branch, use `but reword -m "new-name"` (unapplied branches cannot be renamed). + +### `but branch new [name]` + +Create a new branch. + +```bash +but branch new # Generated branch name +but branch new feature # Independent branch (parallel work) +but branch new feature -a # Stacked branch (dependent work) +``` + +Use parallel branches for independent tasks. Use stacked branches when work depends on another branch. + +In single-branch mode (no managed workspace), `but branch new` stacks the new branch above the checked-out branch (or the `-a` anchor). When the new branch lands above the checked-out branch — always the case without an anchor — it is checked out and `HEAD` moves to it. + +For "commit these selected changes on a new branch", prefer `but commit -b -m "message" ` instead of a separate `but branch new` or preflight `but status -fv` — `-b` creates the branch when it does not exist. + +### `but apply ` + +Activate a branch in the workspace. + +```bash +but apply feature-branch # Activate branch in workspace +``` + +Default human output reports whether the branch was applied, was already active, or conflicted. Conflicts are reported as non-zero CLI errors. + +### `but unapply ` + +Deactivate a branch from the workspace. + +```bash +but unapply # Deactivate branch from workspace +``` + +The command also accepts a current CLI ID pointing to a stack or branch, but agents should use the full branch name. The entire stack containing that branch will be unapplied. + +### `but branch delete ` + +Delete a branch. + +```bash +but branch delete +but branch -d # Short form +``` + +### `but branch show ` + +Show commits ahead of base for a branch. + +```bash +but branch show +but branch show -f # Show files modified in each commit with line counts +but branch show --ai # Generate AI summary of branch changes +but branch show --check # Check if branch merges cleanly into upstream +but branch show -r # Fetch and display review information +``` + +### `but pick [SOURCES]...` + +Cherry-pick commits from unapplied branches into applied branches. + +```bash +but pick --branch # Pick specific commit into branch +but pick --branch # Pick using CLI ID (e.g., "nn") +``` + +Name both the source commit and the target branch. Omitting the target prompts +for one when several branches exist. The source can be a commit SHA (full or +short) or a CLI ID from `but status`. + +## Committing + +### `but commit [CHANGES]...` + +Create a commit. Changes are positional CLI IDs; where the commit goes is a flag. + +```bash +but commit -b -m "message" # Commit ALL uncommitted changes to branch +but commit -b -m "message" # Commit specific files or hunks by CLI ID +but commit -b -m "msg" -m "body" # Repeat -m; parts joined by a blank line +but commit --above -m "message" # Place the commit above a commit or branch +but commit --below -m "message" # Place the commit below a commit or branch +but commit -b --no-message # Commit without a message +but commit --empty -b -m "message" # Insert an empty commit +``` + +**Where the commit goes:** `-b`/`--branch`, `-A`/`--above`, and `-B`/`--below` are mutually exclusive. + +- `-b ` places the commit at the tip of ``, creating it as an unstacked branch if it does not exist. `-b` with no value creates a branch with a generated name. Targeting a branch that exists but is not applied is an error — except a branch checked out in a linked worktree (experimental worktree flag), which is targeted at its tip, as is a worktree named directly. +- `--above ` / `--below ` insert relative to a commit on that commit's branch. Against a branch, they create a new branch above/below it. Against a linked worktree (experimental worktree flag), `--below` targets the tip of its checked-out branch and `--above` is refused. +- With no branches applied, a new branch is created. With one applied stack, the commit goes to its top branch's tip. With more than one stack, a targeting flag is **required** — otherwise the command fails with "Unclear where to commit. Found more than one stack". The gate is stacks, not branches: several branches stacked together take an untargeted commit on the stack's top branch. + +**Important:** `but commit -b -m "msg"` with no IDs commits ALL uncommitted changes. Pass IDs to commit only specific files or hunks. + +`but commit` is not supported from linked worktrees. Use Git directly for the worktree-local commit, and do not run `but setup` there. + +**Committing specific files or hunks:** Start with `but diff` for selective dirty commits, then pass CLI IDs as positional arguments: +- **File IDs** from `but diff` or `but status -fv`: commits entire files +- **Hunk IDs** (`:`) from `but diff`: commits individual hunks +- IDs are space-separated (` `). Commas are not separators — `a1,b2` is parsed as a single ID and fails to resolve. + +**Placing commits:** Use `--above ` or `--below ` when the new commit should be inserted at a specific position in existing history. Change-ID refs of existing commits remain valid after an insertion; sha and `#N`-suffixed refs may go stale — add `--status-after` when subsequent history edits need fresh refs. + +**Several commits from one diff:** Chain `but commit` calls with `&&` to split a broad uncommitted change into several semantic commits: `but commit -b -m "msg1" a1 b2 && but commit -b -m "msg2" c3 d4`. Mutation output is concise by default. Add `--status-after` only when the next step needs workspace IDs or details that the mutation result does not provide. The commits stack in the order you write them — the first `but commit` is the oldest of the new commits and each later one goes on top (newest). File/hunk IDs copied from the original output generally remain usable across commits; if an ID stops resolving, re-read the diff and continue. History edits (`amend`, `squash`, `move`, `uncommit`, `reword`) may run in sequence off one status read when every commit ref involved is a change-ID ref; run them one at a time with `--status-after` when a ref is sha-based or `#N`-suffixed, or when the next command needs freshly issued IDs. Bare `but diff` needs no ID from the preceding command, so `but uncommit && but diff` is safe. If commits from that branch must stay *above* the new ones, see "Split an existing commit" in SKILL.md: commit the replacements, then move the preserved block together with `but move [...] -b ` so its internal order stays intact. + +Example: `but commit -b my-branch -m "Fix bug" ab cd` commits files/hunks `ab` and `cd`. + +Example new branch: `but commit -b feature/contact-form -m "Validate contact form input" ab cd` creates `feature/contact-form` and commits only those selected file or hunk IDs. + +To commit specific hunks from a file with multiple changes, use `but diff` to see hunk IDs, then specify them individually. + +Edge case: if wanted and unwanted edits are in the same hunk, GitButler cannot split that hunk by ID. Only when the task requires keeping part of that hunk uncommitted, temporarily edit the working tree to isolate the wanted lines, commit those IDs, then restore the leftover lines so they remain uncommitted. + +## Editing History + +### `but squash ... [-t ]` + +Move changes into a target. Sources are positional; the target is `-t`/`--target`. +This one command covers squashing, amending, and uncommitting. + +```bash +but squash -t -m "msg" # Squash one commit into another +but squash -t -m "msg" # Squash several commits into a target +but squash -m "msg" # Squash all commits on a branch into one +but squash -t -m "msg" # Squash a branch into a commit, removing the branch +but squash -t -m "msg" # Target a branch: squashes into its newest commit +but squash -t # Amend an uncommitted change (`but amend` does this) +but squash zz -t # Amend all uncommitted changes into a commit +but squash -t zz # Uncommit a commit +but squash -t zz # Uncommit all commits and remove the branch +but squash : -t # Move a committed file into another commit +``` + +All sources must be the same kind (all commits, all branches, all uncommitted changes, `zz`, or all +committed files) and committed-file sources must come from one commit. If `-t` is omitted, `` +must be exactly one branch, which squashes that branch's commits together. + +Message flags (mutually exclusive). Commit and branch sources compose a new message unless the +target is `zz`, so without a flag they open an editor and block — always pass one. Uncommitted and +committed-file sources reuse the target's message and need no flag: + +```bash +-m "msg" # New message; repeat -m to append paragraphs +--no-message # No message +-u, --use-target-message # Keep the target's message, drop the sources' +--use-source-message # Keep the sources' message, drop the target's +``` + +None of the message flags may be used when the target is `zz`. + +For multiple independent squash groups, prefer newer/top groups first; change-ID refs from +one status read stay valid across squashes (the target keeps its ref), so the +groups may run in sequence — use `--status-after` to get fresh refs only +when a ref is sha-based or `#N`-suffixed. + +### `but amend -t ...` + +Amend uncommitted files/hunks into a specific commit. Use when you know exactly which commit the change belongs to — prefer it over the equivalent `squash` form. Sources must be uncommitted; `amend` rejects commits and committed files, so use `squash` or `move` for those. A branch target resolves to that branch's newest commit, so name the commit explicitly when the change belongs further down. + +```bash +but amend -t +but amend -t # Amends into the branch's newest commit (its tip) +``` + +Decide the target commit yourself: check `but status -fv`, find the commit the change logically belongs to, then amend into it. + +### `but move ... <--above|--below|--branch|--unstack>` + +Move commits, committed files, or a branch to a different location. Sources are positional and +space-separated; a target flag is required. + +```bash +but move --below # Place below target (older) — matches status order +but move --above # Place above target (newer) +but move --below # Move an adjacent block in one command +but move --above # Same block move, anchored from the other side +but move -b # Move commit to the tip of a branch (created if missing) +but move --unstack # Move commit onto a new unstacked branch +but move --above # Stack branch on top of target branch +but move --unstack # Tear off (unstack) a branch +but move : --above # Move a committed file into a new commit above another +``` + +Sources may not mix kinds, all committed files must come from the same commit, and only one branch +may be moved at a time. Source order does not matter. For a branch source only `--above` and +`--unstack` apply; `--below` and `-b ` require commit or committed-file sources. `--branch` +with no value is equivalent to `--unstack`. With the experimental worktree flag on, `-b` also +accepts a linked worktree or the branch checked out in it, moving commit or committed-file +sources onto that branch's tip (nothing is created); a branch source is refused there. + +### `but uncommit ...` + +Move commits, branches, or committed files back to the uncommitted area. + +```bash +but uncommit # Uncommit an entire commit +but uncommit # Uncommit all commits and remove the branch +but uncommit : # Uncommit one file from its commit +``` + +Multiple whole commits or multiple branches may be passed together, but source kinds cannot be +mixed. Uncommitting a branch also removes an empty branch. Multiple committed-file sources must all +come from the same commit; uncommit files from different commits in separate commands. + +When you need file and hunk IDs to recommit selectively, use +`but uncommit && but diff` in one shell call. + +### `but reword ` + +Reword commit message or rename branch. + +```bash +but reword -m "new" # Always pass -m; without it an editor opens and blocks +but reword -m "new-name" # Rename a branch (applied branches only) +but reword --fix-formatting # Format to 72-char wrapping +``` + +### `but discard ...` + +Permanently drop branches, commits, or changes. Undo with `but undo`. + +```bash +but discard # Discard an uncommitted file's changes +but discard # Discard a single hunk +but discard zz # Discard all uncommitted changes +but discard # Drop a commit +but discard : # Drop one file's changes from its commit +but discard # Drop a branch and its commits +``` + +All provided IDs must be the same kind, and committed files must come from the same commit. + +## Conflict Resolution + +When commits have conflicts (history-editing commands warn about newly conflicted commits in their output; the `but pull` summary lists them; `but status` marks them as conflicted): + +### `but resolve ` + +Enter resolution mode for a conflicted commit. + +```bash +but resolve +``` + +### `but resolve ...` + +Mark uncommitted files that `but status` lists as `{conflicted}` resolved with their current worktree content (or as deleted). They then show as ordinary uncommitted changes. + +```bash +but resolve src/lib.rs +``` + +### `but resolve status` + +Show remaining conflicted files. + +```bash +but resolve status +``` + +### `but resolve finish` + +Finalize conflict resolution. + +```bash +but resolve finish +but resolve finish --status-after # When clearing the last conflict and its workspace is needed +``` + +The concise result reports leftover markers, surviving uncommitted changes, every remaining +conflicted commit, and the exact current `but resolve ` command. Add `--status-after` to the +finish you expect to clear the last conflict only when the task needs the complete resulting +workspace. When it says no conflicted commits remain, stop; do not run a verification status. + +### `but resolve cancel` + +Cancel conflict resolution and return to workspace mode. + +```bash +but resolve cancel +but resolve cancel --force +``` + +**Workflow:** + +1. `but resolve ` — enter resolution mode using the commit ID from the `but pull` summary (or `but status`); the conflict regions are printed with line numbers +2. Edit the conflicted files — remove every marker (`<<<<<<<`, `|||||||`, `=======`, `>>>>>>>`; conflicts are diff3-style, so the `|||||||` common-ancestor section is present too) and keep the correct content (`but resolve status` re-lists what remains when several files are conflicted) +3. Finalize with `but resolve finish`; add `--status-after` to the finish you expect to clear the last conflict only when the task needs the complete resulting workspace. When it says no conflicted commits remain, stop; do not run a verification status +4. If multiple commits are conflicted, repeat steps 1-3 for each one, oldest commit first — finishing a lower commit rebases the ones above it + +**Important:** Never use `git add`, `git commit`, or other git write commands during conflict resolution. Only use `but resolve` commands and edit files directly. + +## Remote Operations + +### `but push ` + +Push a selected branch and its ancestors to the remote. To update a whole stack, select its top branch once; never loop over the branches. Always specify which branch to push: without one, `but push` prompts for a selection in interactive terminals (one entry per stack, folding in stack ancestors) and otherwise pushes all unpushed work — one push per stack via its topmost unpushed branch, so output has one entry per stack, not per branch. A batch push exits non-zero if any stack failed; stacks that already pushed stay pushed, and rerunning after fixing the failure is safe since up-to-date stacks are skipped. Accepts a full branch name or a branch CLI ID — prefer the name; it stays valid across mutations. + +```bash +but push # Push the selected branch and its ancestors +but push --dry-run # Preview what would be pushed +but push -s # Skip force push protection checks +but push --no-hooks # Bypass pre-push hooks (--no-verify also works) +``` + +Force push is enabled by default with protection checks. Use `-s` only when intentionally skipping those checks. +After a successful push, GitButler also synchronizes PR targets and stack descriptions for the +selected branch and its ancestors. A forge update failure is reported as a warning; it does not +turn the completed Git push into a failure. + +### `but pull` + +Update applied branches onto the latest target branch changes (usually `main`). +Use this for "get latest from main" in a GitButler workspace. + +```bash +but pull # Fetch and rebase applied branches +but pull --check # Dry-run preview: report what would happen, change nothing +``` + +Run `but pull` directly for a straightforward update; its output reports the result and `but undo` +reverts it. Use `--check` first when the user or repository policy requires a preview without +updating. +Do not use raw `git pull` or `git rebase`. + +### `but pr` + +Create and manage pull requests. + +```bash +but pr new -m "Title..." # Push branch and create PR (recommended); first message line is title, rest is description +but pr new -F pr_message.txt # Use file: first line is title, rest is description +but pr new -t # Use default content (commit message), skip prompts +but pr new --draft # Create as draft +but pr new --no-hooks # Bypass pre-push hooks (--no-verify also works) +but pr new -s # Skip force-push protection checks +but pr --draft # Top-level draft flag +but pr auto-merge # Enable auto-merge +but pr set-draft # Mark review as draft +but pr set-ready # Mark review as ready +``` + +**Key behavior:** `but pr new` automatically pushes the selected branch and its ancestors before creating the PR. No need to run `but push` first. Force push and pre-push hooks run by default. +Use `--no-hooks` to bypass pre-push hooks when needed. +Review creation remains successful if the follow-up stack synchronization fails, and reports that +partial success as a warning. + +Selectors for `auto-merge`, `set-draft`, and `set-ready` can be branch names, branch IDs, stack IDs, or numeric review IDs, comma-separated. + +Agents must use `--message (-m)`, `--file (-F)`, or `--default (-t)` to avoid editor prompts. The `-t` flag uses the commit message as title/description for single-commit branches; for multi-commit branches it falls back to the branch name as the title. + +**Stacked branches:** Use `but pr` for stacked PRs. It creates reviews against the right bases and updates GitButler stack footers in PR descriptions. Creating stacked PRs with `gh pr create` or another forge tool loses that stack-aware behavior. To publish a whole stack, run `but pr new -t`; custom messages (`-m` or `-F`) only apply to the selected branch, while dependent branches use default messages (commit title/description). + +When the selected branch sits on dependencies that already have PRs, the summary lists those as "PR already exists for ..." and ends with the newly created review. The already-exists lines are normal stack reporting, not a failure to create the selected branch's PR. + +Requires forge integration to be configured via `but config forge auth`. + +Same-repository pull requests are automatically registered with GitHub's native stacked pull +requests API when the repository is enrolled in GitHub's private preview; otherwise GitButler uses +description footers. `but config forge github-stacks disable` opts out. The setting is +project-local and shared with Desktop. + +### `but land ` + +Land a branch directly onto the target (e.g. `origin/master`), skipping a pull request. Fast-forwards +when possible, otherwise makes a signed merge commit; for a `gb-local` target it moves the refs +locally. Then reconciles the remaining branches like `but pull`, and deletes each landed branch's +copy on the push remote (only when fully contained in the landed target), reported as +`Deleted / (landed)`. + +```bash +but land --yes # Land onto the target (--yes required non-interactively) +but land --no-ff --yes # Force a merge commit instead of fast-forwarding +but land --whole-stack --yes # Land an entire stack by naming its top segment +``` + +Direct target updates are hard to reverse, so confirmation is required (agents must pass `--yes`). +A branch stacked on other segments is refused (its tip would also publish them); `--whole-stack` +is the explicit opt-in, and only the stack's top segment can be named with it. + +## Workspace Maintenance + +### `but clean` + +Remove empty branches from the workspace. + +```bash +but clean # Delete all empty branches +but clean --dry-run # Preview which branches would be deleted +but clean --pull # Pull latest changes first, then clean +but clean --include-upstream # Also remove branches with upstream-only commits +``` + +A branch is considered empty if it has no local commits and no assigned changes. Branches with upstream-only commits are preserved by default unless `--include-upstream` is used. + +The entire operation is a single oplog entry — use `but undo` to restore all deleted branches. + +### `but worktree` + +Manage linked git worktrees (experimental worktree flag). `but wt` is a default alias. + +```bash +but worktree list # Active worktrees with IDs, plus the 3 most recent archived ones +but worktree list --archived # All archived worktrees (`--active` for all active ones) +but worktree archive # Hide a worktree from the workspace +but worktree unarchive # Show it again; archived worktrees have no ID +but worktree remove [-f] # Like `git worktree remove`; `-f` for uncommitted changes +``` + +Worktrees are listed most recently updated first, as `id name (refs/heads/branch) - path`, with the branch shown only when it differs from the worktree name. Archiving is a GitButler-only state; none of these take part in `but undo`. + +## History & Undo + +### `but undo` / `but redo` + +Undo or redo operations. + +```bash +but undo +but redo +``` + +### `but oplog` + +View operation history. + +```bash +but oplog +but oplog list --since +but oplog list --snapshot +but oplog snapshot -m "known good" +``` + +Shows all operations with snapshot IDs. + +### `but oplog restore ` + +Restore to a specific oplog snapshot. + +```bash +but oplog restore +``` + +## Setup & Configuration + +### `but setup` + +Initialize GitButler in current git repository. + +```bash +but setup +but setup --init # Also initialize a new git repo if none exists +``` + +Converts a regular Git repository to the managed GitButler workspace model. Use `--init` in +non-interactive environments (CI/CD) to ensure a Git repository exists before setup. When the +experimental single-branch feature is enabled, normal CLI use does not require this command: the +repository is registered and its target is inferred lazily without checking out +`gitbutler/workspace` or installing setup hooks. + +Rerunning `but setup` on an already-configured project also repairs a missing default target — for +example if `virtual_branches.toml` was reset while the target survived in Git config — so it is the +recovery path when target configuration looks broken. + +### `but teardown` + +Exit GitButler mode and return to normal git workflow. + +```bash +but teardown +``` + +### `but config` + +View and manage GitButler configuration. + +```bash +but config +but config user # Also: forge, target, metrics, feature, ui, ai +but config ai openai # Also: anthropic, ollama, lmstudio, openrouter +but config target # Show the current target branch +but config target origin/main # Set the fetch target +but config push-remote # Show the current push remote +but config push-remote origin # Set the push remote without changing the target +but config feature # List configurable feature flags +but config feature single-branch # Show a feature flag's value +but config feature single-branch enable # Also: disable +``` + +### `but update check` + +Manage GitButler CLI and app updates. + +```bash +but update check +but update install +but update install [nightly|release|0.18.7] +``` + +### `but skill` + +Manage installed GitButler skill files. + +```bash +but skill check +but skill check --update +but skill install --detect +``` + +## Selected Options + +Useful to agents: + +- `-C, --current-dir ` - Run as if started in different directory +- `-h, --help` - Show help for command. Avoid routine help probes; use this reference first. + +## External commands (PATH helpers) + +> Important: Not available for Windows yet + +Similar to Git, if `` is not a built-in `but` command and `but-` exists on `PATH`, `but` runs that executable instead (for example `but forecast …` invokes `but-forecast …`). + +Restriction: `` must consist of characters in the set `[a-zA-Z_-]` + +## Getting More Help + +```bash +but --help # List all commands +but --help # Detailed help for specific command +``` + +Prefer this reference over exploratory help calls. Use command-specific help when required syntax +is missing or a command fails; use top-level help only to discover an undocumented command. + +Full documentation: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c915ade --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ + +## Version control + +- Use GitButler (`but`) for version-control inspection and write operations, including status, diffs, branching, committing, pushing, and history edits. +- Assume multiple agents may be working in this repository. Do not move, amend, squash, discard, commit, push, or otherwise modify another agent's work unless the user asks. +- For commit just/only/specific changes on a new branch (selected-change requests), use the two-command fast path from the GitButler skill: `but diff`, then `but commit -b -m "message" `. +- For that fast path, after the commit succeeds, stop and summarize; do not run separate branch, staging, status, or diff commands unless the commit output is missing information you need. +- Use the installed GitButler skill for command recipes and syntax before guessing flags, using `--help`, or translating Git habits directly. +- Mutation commands report their result without appending workspace status. Add `--status-after` only when the next step needs resulting workspace IDs or details; otherwise do not rerun status or diff to verify success. +- Use a dedicated GitButler branch for each agent session, unless the user asks for a different branch structure. Commit only changes that belong to that session. +- Do not push or open pull requests unless the user asks. +- Keep commit messages and pull request descriptions succinct: explain what changed, why it changed, and any important decision. + +### Amend local fixes into the right commits + +- For small cleanup or follow-up fixes, amend an unpublished local commit when the change clearly belongs with that commit's intent. +- Do not create tiny fixup commits unless the user asks. +- Use GitButler to move the relevant changes into the commit where they belong. +- Ask before rewriting pushed, reviewed, shared, or ambiguous history. + +### Split unrelated changes into separate commits + +- If one file contains unrelated changes, split them by hunk instead of committing the whole file. +- Keep tests with the behavior they verify. +- Split generated output, docs-only edits, or mechanical cleanup into separate commits when each commit remains coherent on its own. +- If the split is ambiguous, summarize the options before committing. + +### Create stacked pull requests + +- If this session depends on another in-flight branch, stack its branch on top of that dependency instead of mixing the changes. +- If this session is working in a stack, put commits on the branch where they belong. +- Ask before moving commits onto lower, pushed, reviewed, or shared branches. +- Use `but move` for branch stacking and restacking. Do not recreate branches to simulate stacking. +- For stacked branches, create pull requests with `but pr`, not `gh`, so GitButler keeps the right PR base branches and stack metadata. + +### Update from the target branch automatically + +- When GitButler status shows new changes on the target branch and the workspace holds only this session's branches, update with `but pull` directly — its output reports the result and `but undo` reverts it. +- If an update you started on your own initiative reports conflicted commits, stop and ask before resolving them (`but undo` reverts the pull if the user prefers). +- When other agents' branches are applied, run `but pull --check` first and ask before updating if it reports conflicts or their branches would move. +- If the user asks you to handle update conflicts, use GitButler's conflict tools. Ask before resolving semantic conflicts, dependency updates, generated files, or conflicts involving another person's work. + +### Open draft pull requests by default + +- When asked to open a pull request, create it as a draft with GitButler unless the user says it is ready for review. +- Remember that creating a draft pull request still publishes the branch. + +### Commit checkpoints after each turn + +- Commit after a working checkpoint, when the requested change is complete and relevant checks have passed or been reported. +- Treat checkpoint commits as local savepoints, not final review history. +- When the user asks you to tidy the history, use GitButler to squash commits, reword commits, and move changes between commits where appropriate. +- Only tidy unpublished local history unless the user explicitly authorizes changing pushed or shared history. + From 5baf805b23a87d509d4b9bfb9b75a2594eaea276 Mon Sep 17 00:00:00 2001 From: Kenneth Domingo Date: Sun, 11 Oct 2026 18:02:17 +0800 Subject: [PATCH 2/3] feat(overrides): add many option for list-wrapped override types (#3) * docs: gitbutler setup * docs(openspec): propose override many option Add an OpenSpec change for a 'many' override flag that wraps py_type in a list, so py_import works for lists of user types. py_import now requires a single-identifier py_type. Co-Authored-By: Claude Opus 5.5 * feat(overrides): add many option for list-wrapped override types py_import with a generic py_type produced invalid imports. many: true wraps the override type in a list; py_import now requires a single identifier. Co-Authored-By: Claude Opus 5.5 * docs: [skip ci] archive override many spec Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- README.md | 8 ++ internal/config.go | 7 +- internal/config_test.go | 3 +- .../endtoend/testdata/batch_exec/sqlc.yaml | 2 +- .../testdata/batch_one_many/sqlc.yaml | 2 +- .../testdata/constant_name_conflict/sqlc.yaml | 2 +- internal/endtoend/testdata/copyfrom/sqlc.yaml | 2 +- .../testdata/emit_aware_datetime/sqlc.yaml | 2 +- .../emit_aware_datetime_no_pydantic/sqlc.yaml | 2 +- .../testdata/emit_generic_querier/sqlc.yaml | 2 +- .../testdata/emit_modern_types/sqlc.yaml | 2 +- .../testdata/emit_pydantic_models/sqlc.yaml | 2 +- .../testdata/emit_querier_protocol/sqlc.yaml | 2 +- .../testdata/emit_query_errors/sqlc.yaml | 2 +- .../emit_query_errors_conflict/sqlc.yaml | 2 +- .../endtoend/testdata/emit_str_enum/sqlc.yaml | 2 +- .../testdata/empty_params_class/sqlc.yaml | 2 +- .../testdata/enum_member_names/sqlc.yaml | 2 +- .../endtoend/testdata/exec_result/sqlc.yaml | 2 +- .../endtoend/testdata/exec_rows/sqlc.yaml | 2 +- .../testdata/feature_matrix_classic/sqlc.yaml | 2 +- .../testdata/feature_matrix_modern/sqlc.yaml | 2 +- .../inflection_exclude_table_names/sqlc.yaml | 2 +- .../testdata/method_name_conflict/sqlc.yaml | 2 +- .../method_name_conflict_nfkc/sqlc.yaml | 2 +- .../testdata/method_name_per_file/sqlc.yaml | 2 +- .../testdata/models_conflict/sqlc.yaml | 2 +- .../testdata/multidim_arrays/sqlc.yaml | 2 +- .../testdata/multiline_comments/sqlc.yaml | 2 +- .../testdata/omit_sqlc_version/sqlc.yaml | 2 +- .../testdata/omit_unused_structs/sqlc.yaml | 2 +- .../endtoend/testdata/overrides/db/events.py | 9 +- .../endtoend/testdata/overrides/db/models.py | 3 + .../endtoend/testdata/overrides/schema.sql | 4 +- .../endtoend/testdata/overrides/sqlc.yaml | 10 +- .../testdata/overrides_global/sqlc.yaml | 2 +- .../postgresql_type_aliases/sqlc.yaml | 2 +- .../testdata/python_keywords/sqlc.yaml | 2 +- .../testdata/query_comments/sqlc.yaml | 2 +- .../query_parameter_limit_two/sqlc.yaml | 2 +- .../query_parameter_limit_undefined/sqlc.yaml | 2 +- .../query_parameter_limit_zero/sqlc.yaml | 2 +- .../query_parameter_no_limit/sqlc.yaml | 2 +- internal/endtoend/testdata/rename/sqlc.yaml | 2 +- .../endtoend/testdata/sqlc_embed/sqlc.yaml | 2 +- internal/gen.go | 3 + .../.openspec.yaml | 2 + .../design.md | 44 +++++++++ .../proposal.md | 45 +++++++++ .../specs/codegen-config/spec.md | 94 +++++++++++++++++++ .../tasks.md | 15 +++ openspec/specs/codegen-config/spec.md | 54 ++++++++++- 52 files changed, 331 insertions(+), 46 deletions(-) create mode 100644 openspec/changes/archive/2026-10-11-override-import-generic-types/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-11-override-import-generic-types/design.md create mode 100644 openspec/changes/archive/2026-10-11-override-import-generic-types/proposal.md create mode 100644 openspec/changes/archive/2026-10-11-override-import-generic-types/specs/codegen-config/spec.md create mode 100644 openspec/changes/archive/2026-10-11-override-import-generic-types/tasks.md diff --git a/README.md b/README.md index cf80f76..57aabe6 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,10 @@ options: py_import: uuid # from uuid import UUID - db_type: bytea py_type: bytes # builtin, no import + - db_type: domain_entity + py_type: DomainEntity + py_import: app.my_module.models.domain + many: true # list[DomainEntity] ``` - A `db_type` entry applies to non-null columns, or only to nullable ones with @@ -412,6 +416,10 @@ options: - A generic `py_type` such as `dict[str, decimal.Decimal]` imports the module of every dotted name in it. Bare names other than builtins are not imported, so write `typing.Any`, not `Any`. +- With `py_import`, `py_type` must be a single identifier. For a list of it, + set `many: true` rather than writing `list[DomainEntity]`. The list is + spelled `List[...]` unless `emit_modern_types` is on, sits inside any array + lists, and is wrapped in `Optional[...]` for nullable columns. Overrides and `rename` can also be set once for every codegen block in sqlc's top-level `options`. Codegen `overrides` are checked before global ones, but a diff --git a/internal/config.go b/internal/config.go index 005e6d3..f7d7533 100644 --- a/internal/config.go +++ b/internal/config.go @@ -39,6 +39,7 @@ type Override struct { Nullable bool `json:"nullable,omitempty"` PyType string `json:"py_type,omitempty"` PyImport string `json:"py_import,omitempty"` + Many bool `json:"many,omitempty"` // schema, table and column glob patterns, set when Column is used columnPatterns []string @@ -103,8 +104,8 @@ func (o *Override) parse(defaultSchema string) error { return errors.New("set one of column or db_type") case strings.TrimSpace(o.PyType) == "": return errors.New("py_type must not be empty") - case o.PyImport != "" && strings.Contains(o.PyType, "."): - return errors.New("py_type must be a bare name when py_import is set") + case o.PyImport != "" && !identifierPattern.MatchString(o.PyType): + return errors.New("py_type must be a single identifier when py_import is set; use many: true for a list") } if o.Column == "" { if canonical, ok := pgTypeAliases[o.DBType]; ok { @@ -172,6 +173,8 @@ func (c Config) moduleNames() map[string]struct{} { // attribute, such as dict, str and uuid in "dict[str, uuid.UUID]". var rootNamePattern = regexp.MustCompile(`(?:^|[^.\w])([A-Za-z_]\w*)`) +var identifierPattern = regexp.MustCompile(`^[A-Za-z_]\w*$`) + func globMatch(pattern, name string) bool { ok, _ := path.Match(pattern, name) return ok diff --git a/internal/config_test.go b/internal/config_test.go index c97d4eb..c5b86e2 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -50,7 +50,8 @@ func TestParseConfigInvalidOverrides(t *testing.T) { {`{"py_type": "str"}`, "set one of column or db_type"}, {`{"db_type": "uuid", "column": "a.b", "py_type": "str"}`, "not both"}, {`{"db_type": "uuid", "py_type": " "}`, "py_type must not be empty"}, - {`{"db_type": "uuid", "py_type": "uuid.UUID", "py_import": "uuid"}`, "bare name"}, + {`{"db_type": "uuid", "py_type": "uuid.UUID", "py_import": "uuid"}`, "single identifier"}, + {`{"db_type": "jsonb", "py_type": "list[X]", "py_import": "m"}`, "many: true"}, {`{"column": "id", "py_type": "int"}`, "table.column"}, {`{"column": "a.b.c.d", "py_type": "int"}`, "table.column"}, {`{"column": "a.[b", "py_type": "int"}`, "syntax error"}, diff --git a/internal/endtoend/testdata/batch_exec/sqlc.yaml b/internal/endtoend/testdata/batch_exec/sqlc.yaml index da10353..819e198 100644 --- a/internal/endtoend/testdata/batch_exec/sqlc.yaml +++ b/internal/endtoend/testdata/batch_exec/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/batch_one_many/sqlc.yaml b/internal/endtoend/testdata/batch_one_many/sqlc.yaml index 831d536..68320c3 100644 --- a/internal/endtoend/testdata/batch_one_many/sqlc.yaml +++ b/internal/endtoend/testdata/batch_one_many/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/constant_name_conflict/sqlc.yaml b/internal/endtoend/testdata/constant_name_conflict/sqlc.yaml index b2cb463..4c8bb8b 100644 --- a/internal/endtoend/testdata/constant_name_conflict/sqlc.yaml +++ b/internal/endtoend/testdata/constant_name_conflict/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/copyfrom/sqlc.yaml b/internal/endtoend/testdata/copyfrom/sqlc.yaml index da10353..819e198 100644 --- a/internal/endtoend/testdata/copyfrom/sqlc.yaml +++ b/internal/endtoend/testdata/copyfrom/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/emit_aware_datetime/sqlc.yaml b/internal/endtoend/testdata/emit_aware_datetime/sqlc.yaml index f8e2ad2..b83b8a3 100644 --- a/internal/endtoend/testdata/emit_aware_datetime/sqlc.yaml +++ b/internal/endtoend/testdata/emit_aware_datetime/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/emit_aware_datetime_no_pydantic/sqlc.yaml b/internal/endtoend/testdata/emit_aware_datetime_no_pydantic/sqlc.yaml index a0a4a54..7332b66 100644 --- a/internal/endtoend/testdata/emit_aware_datetime_no_pydantic/sqlc.yaml +++ b/internal/endtoend/testdata/emit_aware_datetime_no_pydantic/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/emit_generic_querier/sqlc.yaml b/internal/endtoend/testdata/emit_generic_querier/sqlc.yaml index 9a271c3..6fa44a2 100644 --- a/internal/endtoend/testdata/emit_generic_querier/sqlc.yaml +++ b/internal/endtoend/testdata/emit_generic_querier/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/emit_modern_types/sqlc.yaml b/internal/endtoend/testdata/emit_modern_types/sqlc.yaml index 6dd87b7..61873d5 100644 --- a/internal/endtoend/testdata/emit_modern_types/sqlc.yaml +++ b/internal/endtoend/testdata/emit_modern_types/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/emit_pydantic_models/sqlc.yaml b/internal/endtoend/testdata/emit_pydantic_models/sqlc.yaml index 2a743ee..b592da4 100644 --- a/internal/endtoend/testdata/emit_pydantic_models/sqlc.yaml +++ b/internal/endtoend/testdata/emit_pydantic_models/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/emit_querier_protocol/sqlc.yaml b/internal/endtoend/testdata/emit_querier_protocol/sqlc.yaml index 37154a2..cb1f8e8 100644 --- a/internal/endtoend/testdata/emit_querier_protocol/sqlc.yaml +++ b/internal/endtoend/testdata/emit_querier_protocol/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/emit_query_errors/sqlc.yaml b/internal/endtoend/testdata/emit_query_errors/sqlc.yaml index e1d6403..ac42352 100644 --- a/internal/endtoend/testdata/emit_query_errors/sqlc.yaml +++ b/internal/endtoend/testdata/emit_query_errors/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/emit_query_errors_conflict/sqlc.yaml b/internal/endtoend/testdata/emit_query_errors_conflict/sqlc.yaml index 9ccc7e9..b9459f4 100644 --- a/internal/endtoend/testdata/emit_query_errors_conflict/sqlc.yaml +++ b/internal/endtoend/testdata/emit_query_errors_conflict/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: errors.sql diff --git a/internal/endtoend/testdata/emit_str_enum/sqlc.yaml b/internal/endtoend/testdata/emit_str_enum/sqlc.yaml index 2bf6c0f..9a068ff 100644 --- a/internal/endtoend/testdata/emit_str_enum/sqlc.yaml +++ b/internal/endtoend/testdata/emit_str_enum/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/empty_params_class/sqlc.yaml b/internal/endtoend/testdata/empty_params_class/sqlc.yaml index 046960d..c524cbc 100644 --- a/internal/endtoend/testdata/empty_params_class/sqlc.yaml +++ b/internal/endtoend/testdata/empty_params_class/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/enum_member_names/sqlc.yaml b/internal/endtoend/testdata/enum_member_names/sqlc.yaml index 2848a1d..6eec1c3 100644 --- a/internal/endtoend/testdata/enum_member_names/sqlc.yaml +++ b/internal/endtoend/testdata/enum_member_names/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/exec_result/sqlc.yaml b/internal/endtoend/testdata/exec_result/sqlc.yaml index 2678643..81613ef 100644 --- a/internal/endtoend/testdata/exec_result/sqlc.yaml +++ b/internal/endtoend/testdata/exec_result/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/exec_rows/sqlc.yaml b/internal/endtoend/testdata/exec_rows/sqlc.yaml index 2678643..81613ef 100644 --- a/internal/endtoend/testdata/exec_rows/sqlc.yaml +++ b/internal/endtoend/testdata/exec_rows/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/feature_matrix_classic/sqlc.yaml b/internal/endtoend/testdata/feature_matrix_classic/sqlc.yaml index 246ff1a..c9d0ade 100644 --- a/internal/endtoend/testdata/feature_matrix_classic/sqlc.yaml +++ b/internal/endtoend/testdata/feature_matrix_classic/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/feature_matrix_modern/sqlc.yaml b/internal/endtoend/testdata/feature_matrix_modern/sqlc.yaml index 12a37cd..688d01b 100644 --- a/internal/endtoend/testdata/feature_matrix_modern/sqlc.yaml +++ b/internal/endtoend/testdata/feature_matrix_modern/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/inflection_exclude_table_names/sqlc.yaml b/internal/endtoend/testdata/inflection_exclude_table_names/sqlc.yaml index 2dbd9d4..bbcb74f 100644 --- a/internal/endtoend/testdata/inflection_exclude_table_names/sqlc.yaml +++ b/internal/endtoend/testdata/inflection_exclude_table_names/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/method_name_conflict/sqlc.yaml b/internal/endtoend/testdata/method_name_conflict/sqlc.yaml index b2cb463..4c8bb8b 100644 --- a/internal/endtoend/testdata/method_name_conflict/sqlc.yaml +++ b/internal/endtoend/testdata/method_name_conflict/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/method_name_conflict_nfkc/sqlc.yaml b/internal/endtoend/testdata/method_name_conflict_nfkc/sqlc.yaml index b2cb463..4c8bb8b 100644 --- a/internal/endtoend/testdata/method_name_conflict_nfkc/sqlc.yaml +++ b/internal/endtoend/testdata/method_name_conflict_nfkc/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/method_name_per_file/sqlc.yaml b/internal/endtoend/testdata/method_name_per_file/sqlc.yaml index 55b7851..de585b6 100644 --- a/internal/endtoend/testdata/method_name_per_file/sqlc.yaml +++ b/internal/endtoend/testdata/method_name_per_file/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query/ diff --git a/internal/endtoend/testdata/models_conflict/sqlc.yaml b/internal/endtoend/testdata/models_conflict/sqlc.yaml index 2dc8b85..c5596f0 100644 --- a/internal/endtoend/testdata/models_conflict/sqlc.yaml +++ b/internal/endtoend/testdata/models_conflict/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: models.sql diff --git a/internal/endtoend/testdata/multidim_arrays/sqlc.yaml b/internal/endtoend/testdata/multidim_arrays/sqlc.yaml index da10353..819e198 100644 --- a/internal/endtoend/testdata/multidim_arrays/sqlc.yaml +++ b/internal/endtoend/testdata/multidim_arrays/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/multiline_comments/sqlc.yaml b/internal/endtoend/testdata/multiline_comments/sqlc.yaml index 2848a1d..6eec1c3 100644 --- a/internal/endtoend/testdata/multiline_comments/sqlc.yaml +++ b/internal/endtoend/testdata/multiline_comments/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/omit_sqlc_version/sqlc.yaml b/internal/endtoend/testdata/omit_sqlc_version/sqlc.yaml index 363faaa..aad83c0 100644 --- a/internal/endtoend/testdata/omit_sqlc_version/sqlc.yaml +++ b/internal/endtoend/testdata/omit_sqlc_version/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/omit_unused_structs/sqlc.yaml b/internal/endtoend/testdata/omit_unused_structs/sqlc.yaml index 04bd17b..387ef43 100644 --- a/internal/endtoend/testdata/omit_unused_structs/sqlc.yaml +++ b/internal/endtoend/testdata/omit_unused_structs/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/overrides/db/events.py b/internal/endtoend/testdata/overrides/db/events.py index 4161800..023b3e3 100644 --- a/internal/endtoend/testdata/overrides/db/events.py +++ b/internal/endtoend/testdata/overrides/db/events.py @@ -10,17 +10,18 @@ import my_lib.types import sqlalchemy import sqlalchemy.orm +from my_lib.tags import Tag from db import models CREATE_EVENT = """-- name: create_event \\:one -INSERT INTO events (payload, extra) VALUES (:p1, :p2) RETURNING id, payload, extra, history, score, rank +INSERT INTO events (payload, extra) VALUES (:p1, :p2) RETURNING id, payload, extra, history, score, rank, tags, labels """ LIST_EVENTS_BY_RANK = """-- name: list_events_by_rank \\:many -SELECT id, payload, extra, history, score, rank FROM events WHERE rank > :p1 AND rank < :p2 +SELECT id, payload, extra, history, score, rank, tags, labels FROM events WHERE rank > :p1 AND rank < :p2 """ @@ -41,6 +42,8 @@ def create_event(self, *, payload: my_lib.types.Payload, extra: Optional[my_lib. history=cast(Optional[List[my_lib.types.Payload]], row[3]), score=cast(my_lib.Box[decimal.Decimal], row[4]), rank=cast(dict[str, uuid.UUID], row[5]), + tags=cast(List[Tag], row[6]), + labels=cast(Optional[List[List[Tag]]], row[7]), ) def list_events_by_rank(self, *, rank: dict[str, uuid.UUID], rank_2: dict[str, uuid.UUID]) -> Iterator[models.Event]: @@ -53,4 +56,6 @@ def list_events_by_rank(self, *, rank: dict[str, uuid.UUID], rank_2: dict[str, u history=cast(Optional[List[my_lib.types.Payload]], row[3]), score=cast(my_lib.Box[decimal.Decimal], row[4]), rank=cast(dict[str, uuid.UUID], row[5]), + tags=cast(List[Tag], row[6]), + labels=cast(Optional[List[List[Tag]]], row[7]), ) diff --git a/internal/endtoend/testdata/overrides/db/models.py b/internal/endtoend/testdata/overrides/db/models.py index 0f5bcd8..99d849e 100644 --- a/internal/endtoend/testdata/overrides/db/models.py +++ b/internal/endtoend/testdata/overrides/db/models.py @@ -10,6 +10,7 @@ import my_ids import my_lib import my_lib.types +from my_lib.tags import Tag @dataclasses.dataclass() @@ -27,6 +28,8 @@ class Event: history: Optional[List[my_lib.types.Payload]] score: my_lib.Box[decimal.Decimal] rank: dict[str, uuid.UUID] + tags: List[Tag] + labels: Optional[List[List[Tag]]] @dataclasses.dataclass() diff --git a/internal/endtoend/testdata/overrides/schema.sql b/internal/endtoend/testdata/overrides/schema.sql index 94187d4..94af19a 100644 --- a/internal/endtoend/testdata/overrides/schema.sql +++ b/internal/endtoend/testdata/overrides/schema.sql @@ -16,5 +16,7 @@ CREATE TABLE events ( extra jsonb, history jsonb[], score double precision NOT NULL, - rank bigint NOT NULL + rank bigint NOT NULL, + tags text NOT NULL, + labels text[] ); diff --git a/internal/endtoend/testdata/overrides/sqlc.yaml b/internal/endtoend/testdata/overrides/sqlc.yaml index 0086123..1eb9dd7 100644 --- a/internal/endtoend/testdata/overrides/sqlc.yaml +++ b/internal/endtoend/testdata/overrides/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: queries @@ -33,3 +33,11 @@ sql: py_type: "my_lib.Box[decimal.Decimal]" - db_type: bigint py_type: "dict[str, uuid.UUID]" + - column: "events.tags" + py_type: Tag + py_import: my_lib.tags + many: true + - column: "events.labels" + py_type: Tag + py_import: my_lib.tags + many: true diff --git a/internal/endtoend/testdata/overrides_global/sqlc.yaml b/internal/endtoend/testdata/overrides_global/sqlc.yaml index be49c2b..eaa8239 100644 --- a/internal/endtoend/testdata/overrides_global/sqlc.yaml +++ b/internal/endtoend/testdata/overrides_global/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/postgresql_type_aliases/sqlc.yaml b/internal/endtoend/testdata/postgresql_type_aliases/sqlc.yaml index b2cb463..4c8bb8b 100644 --- a/internal/endtoend/testdata/postgresql_type_aliases/sqlc.yaml +++ b/internal/endtoend/testdata/postgresql_type_aliases/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/python_keywords/sqlc.yaml b/internal/endtoend/testdata/python_keywords/sqlc.yaml index da10353..819e198 100644 --- a/internal/endtoend/testdata/python_keywords/sqlc.yaml +++ b/internal/endtoend/testdata/python_keywords/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/query_comments/sqlc.yaml b/internal/endtoend/testdata/query_comments/sqlc.yaml index da10353..819e198 100644 --- a/internal/endtoend/testdata/query_comments/sqlc.yaml +++ b/internal/endtoend/testdata/query_comments/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/query_parameter_limit_two/sqlc.yaml b/internal/endtoend/testdata/query_parameter_limit_two/sqlc.yaml index 8766977..d7184f4 100644 --- a/internal/endtoend/testdata/query_parameter_limit_two/sqlc.yaml +++ b/internal/endtoend/testdata/query_parameter_limit_two/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/query_parameter_limit_undefined/sqlc.yaml b/internal/endtoend/testdata/query_parameter_limit_undefined/sqlc.yaml index 2678643..81613ef 100644 --- a/internal/endtoend/testdata/query_parameter_limit_undefined/sqlc.yaml +++ b/internal/endtoend/testdata/query_parameter_limit_undefined/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/query_parameter_limit_zero/sqlc.yaml b/internal/endtoend/testdata/query_parameter_limit_zero/sqlc.yaml index 88e985a..c469832 100644 --- a/internal/endtoend/testdata/query_parameter_limit_zero/sqlc.yaml +++ b/internal/endtoend/testdata/query_parameter_limit_zero/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/query_parameter_no_limit/sqlc.yaml b/internal/endtoend/testdata/query_parameter_no_limit/sqlc.yaml index d671866..889a9d2 100644 --- a/internal/endtoend/testdata/query_parameter_no_limit/sqlc.yaml +++ b/internal/endtoend/testdata/query_parameter_no_limit/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/rename/sqlc.yaml b/internal/endtoend/testdata/rename/sqlc.yaml index 7ef14bf..edc033e 100644 --- a/internal/endtoend/testdata/rename/sqlc.yaml +++ b/internal/endtoend/testdata/rename/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/endtoend/testdata/sqlc_embed/sqlc.yaml b/internal/endtoend/testdata/sqlc_embed/sqlc.yaml index fa651a9..c97acf5 100644 --- a/internal/endtoend/testdata/sqlc_embed/sqlc.yaml +++ b/internal/endtoend/testdata/sqlc_embed/sqlc.yaml @@ -3,7 +3,7 @@ plugins: - name: py wasm: url: file://../../../../bin/sqlc-gen-python.wasm - sha256: "052fc3c2963971e1945aa5dcf5ebbceae2e84b64f9207128220fb859aa66afaf" + sha256: "a9550c28fc0bb7eb327516023a276181ff9336c46323526e275d14ad66c6ffd6" sql: - schema: schema.sql queries: query.sql diff --git a/internal/gen.go b/internal/gen.go index 62831eb..fcc310a 100644 --- a/internal/gen.go +++ b/internal/gen.go @@ -180,6 +180,9 @@ func makePyType(req *plugin.GenerateRequest, conf Config, col *plugin.Column) py if o := conf.findOverride(col, req.Catalog.DefaultSchema); o != nil { typ.InnerType = o.PyType typ.Import = o.PyImport + if o.Many { + typ.ArrayDims++ + } return typ } typ.InnerType = pyInnerType(req, conf, col) diff --git a/openspec/changes/archive/2026-10-11-override-import-generic-types/.openspec.yaml b/openspec/changes/archive/2026-10-11-override-import-generic-types/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-override-import-generic-types/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/archive/2026-10-11-override-import-generic-types/design.md b/openspec/changes/archive/2026-10-11-override-import-generic-types/design.md new file mode 100644 index 0000000..a695bf0 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-override-import-generic-types/design.md @@ -0,0 +1,44 @@ +# Design + +## Context + +`makePyType` in `internal/gen.go` builds a `pyType` with `InnerType`, `Import`, +`ArrayDims` and `IsNull`. For an override, it sets `InnerType` and `Import` from +the entry. `annotation` in `internal/imports.go` wraps the type name in one +`f.list` per `ArrayDims`, then in `f.optional` if `IsNull`. `f.list` already +picks `list[...]` or `typing.List[...]` from `emit_modern_types`. +`Override.parse` in `internal/config.go` rejects only a dotted `py_type` when +`py_import` is set. + +## Goals / Non-Goals + +**Goals:** +- `many: true` gives a list of the override type, with the existing list + spelling and import rules. +- A `py_import` entry with a generic `py_type` fails at config parse time. + +**Non-Goals:** +- Other containers (`set`, `dict`, `tuple`). Users can write a dotted or + builtin generic `py_type` without `py_import`. +- Parsing `py_type` as a Python expression. + +## Decisions + +- **`many` adds one to `ArrayDims`.** In `makePyType`, when the matched + override has `Many`, add one to `typ.ArrayDims`. `annotation` already does the + wrapping, the modern or legacy spelling, the composition with array columns + and the optional wrap, so `many` needs no new code path. + - Alternative: extract the imported names from a generic `py_type` such as + `list[DomainEntity]`. Rejected. It needs a fixed list of builtin names, it + imports `typing` names such as `Optional` from the wrong module, and it is + more code. +- **Identifier check:** with `py_import` set, `py_type` must match + `^[A-Za-z_]\w*$`. This replaces the dot-only check. The error is + `py_type must be a single identifier when py_import is set; use many: true for a list`. + `parseConfig` already prefixes the error with the entry. The existing test + that expects "bare name" changes to the new message. + +## Risks / Trade-offs + +- [The stricter check rejects any `py_import` `py_type` with brackets.] → Such + configs always generated invalid imports, so nothing that works breaks. diff --git a/openspec/changes/archive/2026-10-11-override-import-generic-types/proposal.md b/openspec/changes/archive/2026-10-11-override-import-generic-types/proposal.md new file mode 100644 index 0000000..0c545f5 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-override-import-generic-types/proposal.md @@ -0,0 +1,45 @@ +# Proposal + +## Why + +An override with `py_import` and a generic `py_type` such as +`{db_type: domain_entity, py_import: app.my_module.models.domain, py_type: list[DomainEntity]}` +generates `from app.my_module.models.domain import list[DomainEntity]`, which is +invalid Python. The plugin uses the whole `py_type` string as the imported name. +Users who want a list of their own type cannot use `py_import` at all. + +## What Changes + +- New override option `many` (boolean, default `false`). When `true`, the + generated annotation is a list of `py_type`. With + `{py_type: DomainEntity, py_import: app.my_module.models.domain, many: true}` + the plugin emits `from app.my_module.models.domain import DomainEntity` and + annotates the column `list[DomainEntity]`. +- `many` follows the existing list spelling (`list[T]`, or `List[T]` when + `emit_modern_types` is off), works with or without `py_import`, and composes + with array columns and nullability. +- Stricter validation: when `py_import` is set, `py_type` must be a single + Python identifier. A generic such as `list[DomainEntity]` is rejected with an + error that points to `many: true`. Such configs never generated valid code, + so no working config breaks. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `codegen-config`: the "Override imports" requirement enforces a single + identifier with `py_import`, and a new "Override list wrapping" requirement + defines `many`. + +## Impact + +- `internal/config.go`: new `Many` field on `Override`; identifier check in + `Override.parse`. +- `internal/gen.go` (`makePyType`): apply `many` to the override's type. +- Tests: `internal/config_test.go`, and the `overrides` end-to-end fixture + under `internal/endtoend/testdata/overrides/`. +- `README.md`: document `many`. diff --git a/openspec/changes/archive/2026-10-11-override-import-generic-types/specs/codegen-config/spec.md b/openspec/changes/archive/2026-10-11-override-import-generic-types/specs/codegen-config/spec.md new file mode 100644 index 0000000..f690fd2 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-override-import-generic-types/specs/codegen-config/spec.md @@ -0,0 +1,94 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Override imports + +For every dotted name in `py_type`, the plugin SHALL add `import ` to +every generated file that uses the type, where `` is the name without +its last segment. This SHALL also apply to dotted names inside a generic type +such as `dict[str, decimal.Decimal]`. The annotation SHALL be `py_type` +unchanged. Names without a dot (for example `str`, `bytes` or `dict`) SHALL NOT +be imported. + +An entry MAY also set `py_import`, as alt-sqlc-gen-python and upstream PR #83 +do. Then the plugin SHALL add `from import ` to every file +that uses the type, and the annotation SHALL be the bare `py_type`. When +`py_import` is set, `py_type` SHALL be a single Python identifier. Otherwise the +plugin SHALL reject the entry with an error that names the entry and suggests +`many: true` for a list. + +#### Scenario: Import added only where used + +- **WHEN** `my_lib.types.Payload` appears in `models.py` and no query file uses + it +- **THEN** `models.py` contains `import my_lib.types` and the query files do not + +#### Scenario: py_import form + +- **WHEN** `{column: "authors.id", py_type: UUID, py_import: uuid}` is set +- **THEN** `Author.id` is annotated `UUID`, and `models.py` contains + `from uuid import UUID` + +#### Scenario: py_import with a non-identifier py_type + +- **WHEN** + `{db_type: jsonb, py_type: "list[DomainEntity]", py_import: app.models}` is + set +- **THEN** the plugin fails with an error that names the entry and mentions + `many` + +#### Scenario: Builtin override needs no import + +- **WHEN** `{db_type: bytea, py_type: bytes}` is set +- **THEN** `bytea` columns are annotated `bytes` and no import is added for them + +#### Scenario: Generic py_type + +- **WHEN** + `{db_type: "double precision", py_type: "my_lib.Box[decimal.Decimal]"}` is set +- **THEN** files that use the type contain `import my_lib` and `import decimal` + +## ADDED Requirements + +### Requirement: Override list wrapping + +An `overrides` entry MAY set `many`, a boolean that defaults to `false`. When +`many` is `true`, the plugin SHALL annotate every column and parameter that the +entry matches as a list of the entry's type, spelled `list[T]`, or `List[T]` +when `emit_modern_types` is off. Imports for `py_type` SHALL be the same as +without `many`. For an array column, the list from `many` SHALL be the +innermost list. For a nullable column, the list SHALL be wrapped in the +optional type. + +#### Scenario: List of an imported type + +- **WHEN** + `{db_type: domain_entity, py_type: DomainEntity, py_import: app.my_module.models.domain, many: true}` + is set, `emit_modern_types` is on, and a model has + `domain_entity domain_entity NOT NULL` +- **THEN** the field is annotated `list[DomainEntity]`, and the file contains + `from app.my_module.models.domain import DomainEntity` + +#### Scenario: Legacy list spelling + +- **WHEN** the same entry is set and `emit_modern_types` is off +- **THEN** the field is annotated `List[DomainEntity]` and the file imports + `List` from `typing` + +#### Scenario: Array column + +- **WHEN** an entry with `many: true` and `py_type: my_lib.Tag` matches a + column `tags domain_entity[] NOT NULL` +- **THEN** the column is annotated `list[list[my_lib.Tag]]` + +#### Scenario: Nullable column + +- **WHEN** an entry with `nullable: true`, `many: true` and `py_type: my_lib.Tag` + matches a nullable column, and `emit_modern_types` is on +- **THEN** the column is annotated `list[my_lib.Tag] | None` + +#### Scenario: many defaults to false + +- **WHEN** an entry does not set `many` +- **THEN** matched columns are annotated with `py_type` and no list diff --git a/openspec/changes/archive/2026-10-11-override-import-generic-types/tasks.md b/openspec/changes/archive/2026-10-11-override-import-generic-types/tasks.md new file mode 100644 index 0000000..4c4433f --- /dev/null +++ b/openspec/changes/archive/2026-10-11-override-import-generic-types/tasks.md @@ -0,0 +1,15 @@ +# Tasks + +## 1. Config + +- [x] 1.1 Add `Many bool \`json:"many,omitempty"\`` to `Override` in `internal/config.go`; verify `go build ./...` +- [x] 1.2 In `Override.parse`, replace the dot-only `py_import` check with `^[A-Za-z_]\w*$` and the error from design.md. In `TestParseConfigInvalidOverrides`, update the "bare name" case to the new message and add `{"db_type": "jsonb", "py_type": "list[X]", "py_import": "m"}`; verify with `go test ./internal/` + +## 2. Codegen + +- [x] 2.1 In `makePyType` (`internal/gen.go`), add one to `typ.ArrayDims` when the matched override has `Many`; verify through task 3.1 + +## 3. End-to-end and docs + +- [x] 3.1 In `internal/endtoend/testdata/overrides/`, add `tags text NOT NULL` and `labels text[]` to `events` in `schema.sql`, and overrides `{column: "events.tags", py_type: Tag, py_import: my_lib.tags, many: true}` and `{column: "events.labels", py_type: Tag, py_import: my_lib.tags, many: true}` in `sqlc.yaml`. Rebuild with `make all`, run `sqlc generate` there, and check that `db/models.py` has `from my_lib.tags import Tag`, `tags: List[Tag]` (the fixture does not set `emit_modern_types`) and `labels: Optional[List[List[Tag]]]`. Verify with `make test` +- [x] 3.2 Document `many` in the README "Type Overrides" section (around line 386) with the `DomainEntity` example, and note that `py_type` must be a single identifier with `py_import`; verify by reading the section diff --git a/openspec/specs/codegen-config/spec.md b/openspec/specs/codegen-config/spec.md index 3b043ec..d03869d 100644 --- a/openspec/specs/codegen-config/spec.md +++ b/openspec/specs/codegen-config/spec.md @@ -81,7 +81,9 @@ be imported. An entry MAY also set `py_import`, as alt-sqlc-gen-python and upstream PR #83 do. Then the plugin SHALL add `from import ` to every file that uses the type, and the annotation SHALL be the bare `py_type`. When -`py_import` is set, `py_type` SHALL be a single identifier, with no dot. +`py_import` is set, `py_type` SHALL be a single Python identifier. Otherwise the +plugin SHALL reject the entry with an error that names the entry and suggests +`many: true` for a list. #### Scenario: Import added only where used @@ -95,6 +97,14 @@ that uses the type, and the annotation SHALL be the bare `py_type`. When - **THEN** `Author.id` is annotated `UUID`, and `models.py` contains `from uuid import UUID` +#### Scenario: py_import with a non-identifier py_type + +- **WHEN** + `{db_type: jsonb, py_type: "list[DomainEntity]", py_import: app.models}` is + set +- **THEN** the plugin fails with an error that names the entry and mentions + `many` + #### Scenario: Builtin override needs no import - **WHEN** `{db_type: bytea, py_type: bytes}` is set @@ -106,6 +116,48 @@ that uses the type, and the annotation SHALL be the bare `py_type`. When `{db_type: "double precision", py_type: "my_lib.Box[decimal.Decimal]"}` is set - **THEN** files that use the type contain `import my_lib` and `import decimal` +### Requirement: Override list wrapping + +An `overrides` entry MAY set `many`, a boolean that defaults to `false`. When +`many` is `true`, the plugin SHALL annotate every column and parameter that the +entry matches as a list of the entry's type, spelled `list[T]`, or `List[T]` +when `emit_modern_types` is off. Imports for `py_type` SHALL be the same as +without `many`. For an array column, the list from `many` SHALL be the +innermost list. For a nullable column, the list SHALL be wrapped in the +optional type. + +#### Scenario: List of an imported type + +- **WHEN** + `{db_type: domain_entity, py_type: DomainEntity, py_import: app.my_module.models.domain, many: true}` + is set, `emit_modern_types` is on, and a model has + `domain_entity domain_entity NOT NULL` +- **THEN** the field is annotated `list[DomainEntity]`, and the file contains + `from app.my_module.models.domain import DomainEntity` + +#### Scenario: Legacy list spelling + +- **WHEN** the same entry is set and `emit_modern_types` is off +- **THEN** the field is annotated `List[DomainEntity]` and the file imports + `List` from `typing` + +#### Scenario: Array column + +- **WHEN** an entry with `many: true` and `py_type: my_lib.Tag` matches a + column `tags domain_entity[] NOT NULL` +- **THEN** the column is annotated `list[list[my_lib.Tag]]` + +#### Scenario: Nullable column + +- **WHEN** an entry with `nullable: true`, `many: true` and `py_type: my_lib.Tag` + matches a nullable column, and `emit_modern_types` is on +- **THEN** the column is annotated `list[my_lib.Tag] | None` + +#### Scenario: many defaults to false + +- **WHEN** an entry does not set `many` +- **THEN** matched columns are annotated with `py_type` and no list + ### Requirement: Global overrides The plugin SHALL read `overrides` and `rename` from sqlc's global options for From 5e53e1cad2e85750c993d807d175942c43912a73 Mon Sep 17 00:00:00 2001 From: Kenneth Domingo Date: Sun, 11 Oct 2026 18:06:31 +0800 Subject: [PATCH 3/3] docs: [skip ci] bump to v1.1.0 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 57aabe6..cfe68ff 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ version: "2" plugins: - name: py wasm: - url: https://github.com/kvdomingo/sqlc-gen-python/releases/download/v1.0.1/sqlc-gen-python.wasm - sha256: 3e9767af784b728ec9bd4499619721e5dabdebeb21cf3c7fe86a045d865584d1 + url: https://github.com/kvdomingo/sqlc-gen-python/releases/download/v1.1.0/sqlc-gen-python.wasm + sha256: b89880f4ed5b53c12527562015fd505e5f2af035e1d02460e45ea3fda31afd13 sql: - schema: "schema.sql" queries: "query.sql"