Generator git initialisation & initial commit (opt-out), optional remote push¶
- Authors
- Matt Cockayne, Claude (claude-opus-4-8) (AI drafting assistant)
- Date
- 2026-06-15
- Status
- IMPLEMENTED (2026-06-15 β post-generation git step wired into
generate project;pkg/vcs/repoDiscoverRepository/InitLocal/AddAllprimitives added; opt-out init+commit onmain, opt-in--pushto the release-source-derived remote via the provider-aware auth)
Summary¶
gtb generate project writes a complete skeleton to the destination path β
Go files, templated CI/config files, a .gitignore, and .gtb/manifest.yaml β
then runs go mod tidy and golangci-lint run --fix as post-processing
(runSkeletonPostProcessing). It stops there: the destination is left as an
un-versioned directory. The operator must remember to git init, stage,
and make the initial commit before the new tool is tracked at all, and before
the freshly-rendered CI pipeline can run against a real ref.
This spec proposes adding a post-generation git step to generate project:
when the destination is not already a git repository, initialise it, stage
the generated tree, and make the initial commit β as an opt-out step
(default: do it; skippable via --no-git, a manifest preference, or a wizard
prompt). It further proposes an optional push of that initial commit to the
remote derived from the supplied props.Tool.ReleaseSource (type/host/owner/repo
β forge URL), reusing the provider-aware auth in pkg/vcs/repo delivered by the
provider-aware-repo-auth work.
This is purely additive to the generator pipeline; it touches only the
post-write hook point in internal/generator/skeleton.go and the
generate project command in internal/cmd/generate/project.go. It does not
alter regenerate or remove, which must never init or commit (see
Interaction with regenerate/remove).
Motivation¶
The scaffolded tree is the start of a real repository, but the generator hands back a directory in an in-between state:
- It already emits a
.gitignore(in the common skeleton assets) β it clearly intends the output to be a git repo β yet never initialises one. - The rendered CI files (
.gitlab-ci.yml/.github/workflows/*) and the releaser-pleaser / GoReleaser wiring all assume a versioned repo with a remote. Until the operator hand-runs git, none of that is exercisable. go mod tidyandgolangci-lint --fixalready mutate the tree as part of post-processing; the natural "first commit" boundary is after that settles, which is awkward to reproduce by hand (the manifest hash-refresh step exists precisely because post-processing changes files).
A one-line "we did the initial commit for you" is a meaningful DX win for a
scaffolding command, and the framework already owns a provider-aware git layer
(pkg/vcs/repo) that can init, commit, add a remote, and push with forge-correct
auth β so the optional push is cheap to offer on top.
The step must be opt-out, not opt-in: a brand-new directory almost always
wants to be a repo, and an operator who does not want it can pass --no-git.
The push, by contrast, has real side effects on a remote and is treated more
conservatively (see D6 β Push is opt-in).
Design decisions¶
D1 β A post-generation git step at the existing hook point¶
The git step runs inside generateSkeleton after generateSkeletonFiles
and after runSkeletonPostProcessing + refreshProjectFileHashes, so the
initial commit captures the fully-settled tree (post-go mod tidy,
post-lint-fix, with the final manifest hashes written). It is gated exactly like
post-processing is today: only when the generator filesystem is a real
*afero.OsFs (if _, ok := g.props.FS.(*afero.OsFs); ok), never under the
in-memory FS used in tests, and never under --dry-run (see D8).
The step is a new unexported method (illustrative name runSkeletonGitInit)
on *Generator, invoked from generateSkeleton. Like the existing
post-processing commands, a git failure is non-fatal: the skeleton has
already been written successfully, so a failed init/commit/push is logged as a
warning and generate project still returns success. The new code does not
change the existing return contract of GenerateSkeleton.
D2 β Already-a-repo detection (the hard gate)¶
The init+commit only happens when the destination is not already a git
repository. Detection walks from the destination path upward (matching git's
own discovery semantics) to find an enclosing .git: if generate project -p
some/path targets a subdirectory of an existing repository, that is treated as
"already a repo" and the step is skipped entirely (we must not make a nested
commit inside someone's existing tree, nor git init a subdirectory of a repo).
Implementation note: pkg/vcs/repo.OpenLocal already does init-if-absent
(git.PlainOpen falling back to git.PlainInitWithOptions), which is the wrong
primitive here because it cannot distinguish "opened existing" from "initialised
new", and it does not walk upward. The git step therefore needs an explicit
discovery probe before deciding to act β either a dedicated helper added to
pkg/vcs/repo (preferred, keeps go-git usage in one place β see
D5) or
git.PlainOpenWithOptions(path, &git.PlainOpenOptions{DetectDotGit: true})
checked for a non-error result. When the probe finds a repo, the whole step
(init, commit, and push) is skipped with an INFO log; the generated files
remain in place.
D3 β Init + add + initial commit¶
When the destination is not a repo:
- Init the repository at the destination with an explicit default branch name (see D4).
- Stage the generated tree. Staging respects the just-written
.gitignore(the skeleton emits one), so build artefacts and the like are not committed.go-git'sWorktree.AddWithOptions{All: true}/AddGlobhonours.gitignore; the implementation must confirm the ignore rules are applied (a plainAdd(".")that ignores.gitignorewould be a bug to guard against in tests). - Commit with a conventional initial-commit message (see D7).
The .gtb/manifest.yaml (and its post-processing hash refresh) is committed as
part of the tree, so the very first commit already records the generator state.
D4 β Branch name and author identity¶
Branch name. Init with an explicit default branch rather than relying on the
ambient git default. Proposed default: main. This should be overridable (flag
or manifest) but main is the framework convention and matches the rendered CI
(releaser-pleaser operates on main). pkg/vcs/repo.OpenLocal already shows
the git.PlainInitWithOptions + DefaultBranch shape.
Author identity. The initial commit needs an author. go-git does not
read user.name/user.email from the host git config automatically the way the
git CLI does, so the identity must be resolved explicitly. Proposed resolution
order (an open question to confirm):
- Local/global git config
user.name/user.email, if resolvable. - A GTB-specific config key (e.g.
generator.git.author.{name,email}). - A safe framework fallback (e.g. name
gtb, email derived from the tool / a non-routable placeholder) β used so the commit never fails outright for lack of identity.
This is deliberately conservative: a real human identity is preferred, but the non-fatal contract (D1) means a missing identity must degrade to a fallback, not abort scaffolding.
D5 β Reuse pkg/vcs/repo, not go-git directly¶
The git work routes through pkg/vcs/repo (the RepoLike / role-interface
layer) rather than calling go-git directly from the generator, for three
reasons:
- It is the single place go-git is used and already carries the
Committer(Commit,Push),Authenticator,CreateRemote/Remote, and worktree roles this step needs. - It already resolves provider-aware clone/push auth from the tool's forge
config subtree (
resolveForgeβ<forge>.auth/<forge>.sshβvcs.ResolveToken), exactly what the optional push needs β no second auth path. See provider-aware-repo-auth. - Its method set (init via
OpenLocal,CreateRemote,Push,Commit) is unit-testable against a local bare remote, matching the existingrepo_integration_test.gopatterns.
Gaps to fill in pkg/vcs/repo (each a small, backward-compatible addition):
- An init-only primitive (or a discovery probe + explicit init) so the
generator can distinguish "already a repo" from "newly initialised" β today
OpenLocalconflates them (D2). - Confirm
.gitignore-respecting staging is reachable through the role interface (add a worktreeAdd/AddAllmethod if the public surface does not already expose it βRepoLikecurrently exposesCommit/Pushbut staging goes through the raw worktree viaWithTree).
The generator depends on the narrowest roles it needs (Committer, the
worktree accessor, CreateRemote) per the role-interface guidance in the
provider-aware-repo-auth spec, not the full RepoLike composite.
D6 β Optional push: deriving the remote from Tool.ReleaseSource¶
The push target is derived from the values already collected by generate
project and persisted in .gtb/manifest.yaml β ReleaseSource
(Type/Host/Owner/Repo). The forge URL is built as
https://{host}/{owner}/{repo}.git (with host defaulting per backend β
github.com/gitlab.com β exactly as the generator already defaults it). For
GitLab nested groups the owner segment is the full group path
(group/subgroup), which the URL construction must preserve.
Mechanics:
CreateRemote("origin", []string{forgeURL})on the freshly-initialised repo.Pushthe default branch with auth resolved byNewRepo's provider-aware flow (resolveForgekeys offReleaseSource.Type, overridable byvcs.provider). Token vs SSH selection is identical to clone/push elsewhere.
Auth is not re-implemented: it is whatever pkg/vcs/repo.NewRepo already
configures for this tool's forge. A public repo with no token still gets an
unauthenticated push attempt (which will simply fail at the remote if the
remote requires auth) β the non-fatal contract means that failure is a warning,
not an error.
D6a β When the remote does not exist yet¶
The common case for a brand-new tool is that the remote repository has not
been created on the forge. Pushing to a non-existent remote fails. This spec
proposes that creating the remote repository via the forge API
(pkg/vcs) is OUT OF SCOPE for the first iteration (see
Out of scope and the open question on it):
the push targets an assumed-existing remote, and a failure (remote absent,
auth missing, network down) is logged as an actionable warning with the manual
git push command the operator can run once they have created the repo.
Forge-API repo creation is a plausible follow-up but is a distinct capability
(auth scopes, visibility, default-branch protection) that should not block this
DX win.
D6 β Push is opt-in, not opt-out¶
Unlike init+commit (opt-out,
D1), push
defaults to OFF. Pushing has irreversible-ish side effects on a remote the
operator may not have created, may not intend to populate yet, or may want to
inspect locally first. Push is enabled explicitly via --push (and/or a
manifest/wizard preference). --push implies the git step (it makes no sense to
push without a commit); --no-git with --push is a conflicting-flags error.
This opt-in-for-push / opt-out-for-init split is the safe default and is called
out as an open question for the maintainer to confirm.
D7 β Commit message convention¶
The repo mandates Conventional Commits. The initial commit message should follow suit. Proposed default:
chore: is non-releasing, which is correct for an initial scaffold (the first
real release is cut later). The tool name and gtb version are already available
to the generator (config.Name, g.currentVersion()). The exact wording is an
open question; a feat: initial commit is an alternative if
the maintainer wants the scaffold itself to seed the first changelog entry.
D8 β Dry-run and CI behaviour¶
--dry-run: the git step is not executed. The existing dry-run path (GenerateSkeletonDryRunβwithDryRunOverlay) previews file writes and the post-processing commands without touching disk; the git step is previewed as a described action (e.g. "wouldgit init+ commit on branchmain"; "would push to<url>" when--pushis set) in theDryRunResult, consistent with how post-process commands are surfaced today.- Non-interactive /
--ci: the init+commit still runs (it is opt-out and has no remote side effect), using flag/manifest values and the author-identity fallback β no prompt. The push does not auto-enable under--ci; it remains governed by--push. Whether--cishould additionally suppress the git step entirely (treating CI runs as ephemeral) is an open question.
D9 β Manifest records the preference (withdrawn)¶
Resolved per O7 (2026-06-15): there is no manifest
footprint. The git/no-git (and push) preference is a pure invocation-time
flag with no .gtb/manifest.yaml block. Git-init is a one-time lifecycle
action and regenerate never re-inits, so persisting the preference would only
mislead a manifest-driven regeneration. The earlier proposal β recording
properties.git: { init: true, push: false, branch: main } mirroring how
signing/help/telemetry preferences are persisted β is therefore withdrawn; the
default branch is supplied at invocation time
(D4).
Interaction with regenerate/remove¶
The git step is exclusive to generate project (initial scaffolding).
regenerate(internal/generator/regenerate.go) re-renders into an existing project that is, by definition, already a git repository (it has a committed.gtb/manifest.yaml). The already-a-repo gate (D2) means regenerate would skip init/commit even if the code path were shared β but to be unambiguous, the git step is not wired into the regenerate path at all. Regenerate must never create commits or push on the operator's behalf; it only rewrites files and refreshes hashes, leaving staging/committing to the operator (so they can review the regenerated diff).remove(internal/generator/removal.go) deletes generated files. It must never init, stage, commit, or push. No change.
A test asserting that regenerate/remove on a fresh temp dir produce no new commits is part of the verification plan.
Open questions¶
- O1 β Push default. Confirm push is opt-in (
--push, default off) while init+commit is opt-out (D6). Is opt-in the right safety posture, or should push be opt-out-with-confirmation in interactive mode?
Resolved (2026-06-15): Push is opt-in (--push, default off);
init+commit is opt-out (default on, --no-git to skip). The opt-in /
opt-out split is confirmed as the safe default β push has remote side effects
and stays off unless explicitly requested.
2. O2 β Remote creation scope. Confirm forge-API creation of the remote
repository is out of scope for iteration 1
(D6a), with push targeting an
assumed-existing remote and failing to a warning. Is a follow-up to
create-then-push wanted, and on which forges?
Resolved (2026-06-15): Forge-API remote creation is OUT of scope for
v1. Push targets an assumed-existing remote and degrades to an actionable
warning if the remote is absent. Create-then-push is noted as a plausible
future follow-up but does not block this DX win.
3. O3 β Author identity source. Confirm the resolution order in
D4 (host git config β GTB config key β
framework fallback). go-git does not read host user.* automatically β is
reading the host git config acceptable, or should GTB require its own config
key / prompt?
Resolved (2026-06-15): Resolution order is host git config β GTB config
key β framework fallback. Reading the host user.* git config is
acceptable (and preferred, so the commit carries a real identity); the
framework fallback guarantees the commit never fails for lack of identity.
4. O4 β Commit message convention. Confirm chore: scaffold <tool> with gtb
(D7) vs a feat: initial commit that seeds
the first changelog entry.
Resolved (2026-06-15): The initial commit message is
chore: scaffold <tool> with gtb, NOT feat:. A feat scaffold commit
would make releaser-pleaser cut a release from the empty scaffold, which is
undesirable; chore: is non-releasing and correct here.
5. O5 β Default branch name. Confirm main
(D4) as the hard default, overridable
by flag/manifest.
Resolved (2026-06-15): Default branch is main, overridable by flag.
6. O6 β --ci behaviour. Should --ci/non-interactive suppress the git
step entirely (CI runs as ephemeral), or run init+commit as in
D8 (push still opt-in)?
Resolved (2026-06-15): --ci/non-interactive runs init+commit (it is
harmless and useful for automated scaffolding). --no-git still opts out;
push stays opt-in (it does not auto-enable under --ci).
7. O7 β Manifest preference. Should the git/push preference be persisted in
.gtb/manifest.yaml (D9) or stay a
pure invocation-time flag with no manifest footprint?
Resolved (2026-06-15): No manifest footprint. The git/push preference
stays a pure invocation-time flag. Git-init is a one-time lifecycle action and
regenerate never re-inits, so persisting the preference would only mislead.
D9 is withdrawn accordingly.
8. O8 β pkg/vcs/repo surface. Confirm the small additions to
pkg/vcs/repo (D5): an
init-only/discovery primitive distinct from OpenLocal's init-if-absent, and
a .gitignore-respecting staging method on the role surface. Are these
acceptable additive changes to a Beta-tier package, or should they live in an
internal/ helper to avoid widening the public API?
Resolved (2026-06-15): Add the init/discovery and .gitignore-aware
staging primitives to pkg/vcs/repo β keeping go-git usage in one place.
Additive changes to a pre-1.0 Beta package are acceptable, so these belong on
the public role surface rather than in an internal/ helper.
9. O9 β Staging correctness. Confirm that .gitignore is honoured at stage
time (go-git AddWithOptions), so generated build artefacts are not
committed β and that the .gitignore itself is committed.
Resolved (2026-06-15): Yes β .gitignore is honoured at stage time
(go-git AddWithOptions), so generated build artefacts are excluded, and the
.gitignore file itself IS committed.
Verification plan¶
- Unit β init+commit happens.
generate projectinto a fresh temp dir (real OS FS) leaves a git repo with exactly one commit onmain, whose tree includes.gtb/manifest.yaml,.gitignore, and the Go skeleton. - Unit β already-a-repo gate. Targeting a path inside an existing repo (and a path that is a repo) skips init/commit entirely; no new commit is made, existing history untouched (D2).
- Unit β opt-out.
--no-gitwrites the skeleton with no.gitdirectory. - Unit β
.gitignorehonoured. A path matched by the generated.gitignoreis absent from the initial commit;.gitignoreitself is present (O9). - Unit β author identity fallback. With no resolvable identity, the commit still succeeds using the framework fallback (D4).
- Unit β non-fatal. A forced git failure (e.g. unwritable
.git) yields a warning and a successfulgenerate project, with files still written. - Unit β regenerate/remove make no commits (Interaction).
- Unit β remote URL derivation from
ReleaseSourcefor GitHub (org/repo) and GitLab nested groups (group/subgroup/repo) builds the correcthttps://{host}/{owner}/{repo}.git. - Integration (
INT_TEST_VCS=1) β--pushagainst a local bare remote pushes the initial commit; auth selection follows the provider-aware flow (token vs ssh) β extendsrepo_integration_test.gopatterns. - Dry-run β
--dry-runperforms no git actions and theDryRunResultdescribes the would-be init/commit (and push under--push). - Docs β update
docs/components/(generator) and the relevant how-to/concepts pages; cross-referencepkg/vcs/repo.
Out of scope¶
- Forge-API creation of the remote repository (create-then-push). Push
targets an assumed-existing remote; creating it via
pkg/vcsprovider APIs (visibility, branch protection, scopes) is a separate, larger capability (D6a, O2). - Multi-commit / curated history (e.g. separate commits per skeleton layer). A single initial commit is the contract.
- Signing the initial commit (GPG/SSH-signed commits). The signing generator feature concerns release signature verification, not commit signing; this step makes an unsigned commit.
- Changing
regenerate/removeto commit or push anything (Interaction). - Re-implementing git auth. Auth is whatever
pkg/vcs/repoalready resolves.
Related¶
- Provider-aware repository auth β the
forge-aware clone/push auth (
resolveForge,<forge>.auth/<forge>.ssh,vcs.ResolveToken) the optional push reuses. internal/generator/skeleton.goβGenerateSkeleton/generateSkeleton/runSkeletonPostProcessing(the hook point) andwriteSkeletonManifest.internal/cmd/generate/project.goβ thegenerate projectcommand and its flag/wizard surface where--no-git/--pushare added.pkg/vcs/repo/repo.goβRepoLikeroles (Committer,Authenticator,CreateRemote,OpenLocal),NewRepo's provider-aware auth.pkg/props/tool.goβTool.ReleaseSource(Type/Host/Owner/Repo) the remote URL is derived from.internal/generator/manifest.goβ.gtb/manifest.yaml. Per the resolution of O7, the git/push preference is not recorded here; it is a pure invocation-time flag (D9 withdrawn).