git-scaffold

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:

YAML and automatic json-patch. Applying a json-patch to YAML canonicalizes the whole document: comments are dropped, anchors expanded, and YAML 1.1 scalars such as 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.

Prefer native extension mechanisms over patches where the format offers one — a 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: