Configuration
Configuration is data, not code — plain TOML. The same format
serves both sides: a repository may be a scaffold source, a scaffold target,
or both at once. Every configuration starts with
[scaffold] version = 1, and unknown versions or unknown keys are
rejected.
Target configuration
A target repository keeps its scaffold metadata in
.git-scaffold/:
.git-scaffold/
config.toml
lock
patches/
...
The target's config.toml declares the upstream source, the
argument values, and any explicit overrides:
[scaffold]
version = 1
[source]
git = "https://github.com/acme/go-template.git"
ref = "main" # optional; defaults to the remote HEAD
[args]
project_name = "orders"
module = "github.com/acme/orders"
[overrides.".golangci.yml"]
strategy = "json-patch"
patches = [
"patches/golangci.json"
]
Any Git URL syntax the installed Git supports may be used, and
ref can be a branch, tag, or other ref — git-scaffold simply asks
Git to resolve it to a commit.
Source descriptor
A source repository declares its scaffold interface in its own
.git-scaffold/config.toml: which files it manages, which
arguments targets may or must provide, how those arguments appear as literal
tokens, and which files permit structured patching.
[scaffold]
version = 1
[template]
name = "go-service"
[[arguments]]
name = "project_name"
description = "Project name"
token = "@@PROJECT_NAME@@"
[[arguments]]
name = "module"
description = "Go module path" # no default → required
[[arguments]]
name = "go_version"
default = "1.26" # has a default → optional
token = "@@GO_VERSION@@"
[[files]]
path = "Makefile"
[[files]]
path = "go.mod"
[files.arguments.module]
token = "@@MODULE@@" # per-rule token for this argument
[[files]]
path = ".github/workflows/*.yml"
[[files]]
path = ".golangci.yml"
patch = "json-patch" # permits structured patching
[[files]]
path = "examples/**/*"
substitute = false # no token substitution here
allow-empty = true # glob may match zero files
Only files selected by [[files]] entries are managed; other
files in the source repository are irrelevant to synchronization. This lets
the source simultaneously be an ordinary GitHub template repository holding
additional one-time scaffolding content.
Arguments and tokens
There is no template language. Substitution is literal token replacement:
with token @@PROJECT_NAME@@ and value orders,
github.com/acme/@@PROJECT_NAME@@ becomes
github.com/acme/orders. The token can be any literal string the
source author chooses — @@NAME@@,
{{ project.name }}, __NAME__ — it is never
interpreted as a pattern or expression. All replacements in a file operate
simultaneously against the original content, so a value containing another
token is never re-expanded.
Value resolution per argument: the target's value, else the source's
default, else an error. The argument name is the stable
contract between source and target — targets never need to know token
syntax. Rules may override the token per file rule
([files.arguments.<name>] token = "…"), disable one
argument for a rule (enabled = false), or disable substitution
for a rule entirely (substitute = false).
Globs
A [[files]] path is either an exact file or a glob:
* (within one path component), ? (one character),
and ** (across components), matched with /
separators against the source Git tree at the selected commit. Expansion is
deterministic: matches are normalized and sorted lexicographically. An exact
path that does not exist is an error; a glob matching zero files is an error
unless the rule sets allow-empty = true.
Exact trumps glob — and nothing else
When file rules overlap, exactly one form of precedence exists: an exact path overrides a glob that also matches it. There is no "more specific glob beats less specific glob" — two globs (or two exact rules) matching the same file must imply identical behavior, or the descriptor is rejected as ambiguous. Declaration order never resolves a conflict.
This is by design, not a missing feature. Specificity ordering between patterns invites descriptors whose behavior a reader has to compute rather than read. If one file in a globbed directory needs different treatment, name it — the carve-out is then visible at a glance. The nudge is intentional: scaffold only what you need, with the smallest, most explicit rule set that says it.
Overrides and patches
A target declares patches against concrete repository-relative paths, with
patch files stored relative to .git-scaffold/ and applied in
declaration order, after token substitution:
[overrides."config/production/service.yml"]
strategy = "json-patch"
patches = [
"patches/production-service.json"
]
Two strategies exist in v0.1:
text-patch— conventional unified-diff patching. It is always available to targets as the universal escape hatch, against any managed file, whether or not its rule declarespatch.json-patch— RFC 6902 JSON Patch against JSON and YAML files. A structured strategy is available only where the source rule permits it withpatch = "json-patch".
on, yes or 0755
resolved. init --existing and repatch therefore
generate a json-patch for a YAML file only when the scaffold's own file is
already canonical — in practice, free of comments and anchors. A commented
.golangci.yml falls back to text-patch even when its
rule says patch = "json-patch". This is deliberately conservative
for v0.1; --text-patch opts out of structured adoption entirely.
If the target already declares a json-patch override for the
file, repatch honours that explicit choice regardless of
comments. JSON files are unaffected.
A failed patch fails materialization — there is no silent repair, fuzzy
application, or three-way merge. If a downstream patch is incompatible with a
new upstream version, human resolution is required; a failed
update leaves everything, lock included, unchanged.
Makefile with -include Makefile.local, reusable CI
workflows, native configuration includes. The source owns the managed file;
the target owns the local extension file.
Lock file
.git-scaffold/lock pins the exact source commit currently
materialized. Its complete contents are one full commit SHA and a
newline:
71db8b7497d60831eae98e2d9b70548fdb39f714
No URL, no timestamp, no hashes, no tool version. The configuration describes the desired source lineage; the lock identifies the exact commit. Commit both.
Custom proposal command
For hosting providers without built-in support, a target may configure the
command propose runs to create the pull/merge request. It is an
argument array, executed directly without a shell, with
{{ branch }}, {{ title }}, and
{{ body_file }} placeholders (independent of scaffold argument
substitution):
[propose]
create-command = [
"forge", "pr", "create",
"--head", "{{ branch }}",
"--title", "{{ title }}",
"--body-file", "{{ body_file }}"
]
Global tool configuration
Separate from any repository, optional per-user defaults live in
$XDG_CONFIG_HOME/git-scaffold/config.toml
(~/.config/git-scaffold/config.toml by default; plain XDG on
every platform, matching git's own handling). An absent file leaves the tool
fully functional. Nothing here affects deterministic output.
cache-dir = "~/.cache/git-scaffold" # where source repos are cached
[update-check]
enabled = true
interval = "24h" # Go duration string
Cache location precedence: $GIT_SCAFFOLD_CACHE, else
cache-dir, else $XDG_CACHE_HOME/git-scaffold, else
~/.cache/git-scaffold. The cache is an implementation detail and
never affects output; a locked commit that is available locally lets
check, diff, and apply run without
network access.
Update check
At most once per interval, and only when stderr is a
terminal, git-scaffold checks GitHub for a newer release and prints a
one-line notice to stderr after command output. The check never delays a
command by more than 2 seconds, never changes an exit status, never writes to
stdout, and treats any HTTP failure as a silent no-op. It does not run
when:
update-check.enabled = falsein the global configuration;- the
GIT_SCAFFOLD_NO_UPDATE_CHECKenvironment variable is non-empty; - the
CIenvironment variable is non-empty; - stderr is not a terminal;
- the running version is not a plain semver (dev builds never prompt).