git-workflow
Recipe card from the charly-internals plugin (Development — contributor internals).
This card has additional detail pages:
git-workflow — branch-per-change, PR-only org-workflow-validated landing
Section titled “git-workflow — branch-per-change, PR-only org-workflow-validated landing”Every change to an OpenCharly repo lands through ONE discipline: a pull request
gated by the ORG-WIDE charly/pr-validator GitHub Actions workflow
(opencharly/.github), which validates and, on a PASS verdict, enables GitHub’s
OWN native auto-merge (squash) inline — there is no separate auto-merge
workflow; the CalVer tag and the CHANGELOG are written afterwards by the
independent tag-on-merge workflow, triggered by the merge. A direct
push to main is FORBIDDEN and mechanically disabled — a per-repo branch
RULESET blocks it (creation/deletion/non_fast_forward + a required
validate / validate status check); the legacy branch-protection API is NOT used
and branch-protection.sh actively removes it, because it has no bypass slot for
the app that writes the CHANGELOG. The pre-push-gate adds a local backstop in
every harness that wires it — including Claude Code, via
.claude/settings.json’s PreToolUse hooks. The R10 pass
authorizes OPENING the PR, never a self-merge: the two-step landing separates the
author (who opens the PR) from the fresh validator (a check run on the same PR).
This skill is the mechanics; the project rulebook “Post-Execution Policies” (AGENTS.md / CLAUDE.md) carries the
mandate, /charly-internals:cutover-policy the one-phase rule, /charly-build:migrate
the schema-version/tag coupling, and the marketplace’s internals/agents/pr-validator.md the
validator’s own spec.
Validator re-trigger + PR-body evidence mechanics (FIXED at the workflow level, #38 wave)
Section titled “Validator re-trigger + PR-body evidence mechanics (FIXED at the workflow level, #38 wave)”- A body-only fix needs a NEW commit, and an empty commit IS such a commit. The org
validator reviews the diff at the PR’s head SHA and keys its verdict to that head, so
editing only the body leaves the head unchanged and the review stale. Append a commit —
git commit --allow-empty -m "docs: re-freeze the PR body against the final head"is the standard append-only move (never force) — and the push fires a freshpull_request(synchronize) run on the NEW head: measured on opencharly/plugin-pipeline#28 and opencharly/plugin-vm#35, an empty commit produced a freshcharly/pr-validatorev=pull_requestrun keyed to the new SHA.gh workflow run pr-validator.yml -f pr-number=<N> --ref <branch>is the alternative, but it re-dispatches on the SAME head: GitHub’s rollup keeps BOTH check runs for that name, and a stale earlierfailureof the same name can keep the PRBLOCKEDeven after the dispatched run is green — a fresh SHA from an empty commit is the reliable path. (The “empty commit does nothing” belief is WRONG: the dispatcheron: pull_request: types: [opened, synchronize, …]has nopaths:/diff guard, so a no-content push still fires it.) - A
workflow_dispatchwithout--refruns on the DEFAULT branch and its check registers there — the reusable workflow now re-dispatches itself on the PR head ref (self-heal), so the green check always lands on the branch head. - The workflow now dedupes + self-heals (opencharly/.github pr-validator): a per-PR
concurrencygroup (cancel-in-progress: true) cancels the in-flight run on re-dispatch/push, so duplicate same-name check runs can no longer poison mergeability; aworkflow_dispatchre-dispatches itself on the PR head ref so the green check lands on the head. - The PR body must match a FROZEN head. Commit SHAs and diff-stats in the body are
mutable until the branch stops moving; a rebase/amend after writing the body guarantees
a Rule-4 body-truthfulness BLOCK. Freeze the branch, write the body against the final
head (real
git log --oneline origin/main...HEAD+git diff --statoutput), push ONCE, and never touch the branch again until merge. Any required fix = ONE atomic batch: commit → compute the new head → rewrite the body → push. gh_pr_statusmode:'watch'may act as a one-shot in some environments — use a boundedcheckpoll loop as the fallback (terminal verdicts only, never loop on a BLOCK).
Non-negotiable invariants
Section titled “Non-negotiable invariants”-
No direct push to
main(the project rulebook’s PR-only landing mandate — see “Post-Execution Policies”). Enforced by a per-repo branch RULESET onrefs/heads/main—creation+deletion+non_fast_forward+ a strict required status check named exactlyvalidate / validate, with thecharly-auto-mergeGitHub App as the only bypass actor (its scoped bypass is what lets tag-on-merge’s CHANGELOG commit land on a protectedmain). The LEGACY branch-protection API is deliberately NOT used: it has no bypass slot for that app, sobranch-protection.shdeletes it wherever it survives, andenforce_adminsplays no part. Thepre-push-gateadds a local backstop in every harness that wires it — Claude Code included, via.claude/settings.json’s PreToolUse hooks. Organization-wide apply/verify is owned only byopencharly/.github/scripts/branch-protection.sh. -
Never force-push, on any branch, ever (mandate, same rulebook section).
mainonly fast-forwards via native auto-merge’s squash; afeat/branch, once pushed, advances only by ADDING commits (the author’s change plus any review-round fix commits), and the squash-merge collapses them. A stalefeat/catches up withgh pr update-branch(a merge, NOT a rebase-force); tags are add-only. Amending afeat/branch is a normal authoring action — legal until the first push (amending a pushed branch would require a force-push, which is forbidden). -
R10-gated; the merge requires the
charly/pr-validatorgate’s green check run. R10 PASS authorizes opening the PR (with pasted evidence); a rule violation or R10 FAIL means a redcharly/pr-validatorcheck and no merge — fix in the same tree, re-run R10, re-push; the check resets and the validator re-runs. -
Zero warnings is part of R10 (project rulebook R1). A version-mismatch warning clears with
charly box reconcile; any other warning gets/charly-internals:root-cause-analyzerthen a real fix — “warning” is never an accepted end state. -
Atomic on
main, never onfeat/. The org-widecharly/pr-validatorworkflow’s PASS enables GitHub native auto-merge (squash), which folds the author’s change and any review-round fix commits into one commit onmain; thefeat/branch may freely accumulate fix commits across review rounds. The merge-time CalVer tag and theCHANGELOG/<CalVer>.mdentry (written from the PR body — the PR body IS the changelog) are created after merge by the org-widetag-on-mergeworkflow (see “CalVer” inreferences/validator-and-calver.md). Two separate cutovers must never share one PR. -
Update the PR; never close-and-recreate (except for work that will not land at all — a disproven premise, an abandoned approach). When a review demands changes, append a commit and push it fast-forward — the check resets and the validator re-runs. This is what makes the no-force-push rule livable: because
maingets a squash, a branch carrying five fix commits still lands as one. -
Validator re-review is per-HEAD; body-only edits do not re-trigger it. The org validator reviews the diff at the PR’s head SHA and keys its verdict comment to that head. Editing only the PR body leaves the head unchanged, so a fix that is purely evidentiary (pasting real output, correcting an accounting line) needs a NEW commit to re-validate —
git commit --allow-emptywith a docs message is the standard append-only re-trigger (never force; see the “Validator re-trigger” bullet above for why an empty commit works and a dispatch does not). Corollary: a PR whose diff is EMPTY because the base already contains the change (you branched from a stale snapshot) is a no-op — close it rather than re-pushing (the validator flags it as body-truthfulness violation: body describes files the diff does not carry). Alwaysgit fetch origin main+ diff against CURRENT main before opening or finalizing a PR. -
A dispatched workflow defaults to the DEFAULT BRANCH.
gh workflow run <wf> --ref <branch>is required to dispatch CI on a PR head — a dispatch that lands on main produces no check-run counts toward that PR’s required checks (andworkflow_dispatchwith no--refsilently uses the default branch). For run-on-head diagnostics: target the PR’s branch explicitly. -
Tree-safety before destructive actions (R6). Check
git status+git stash listbefore any destructive working-tree action —git stashdiscards in-progress work;rmon a tracked file is destructive. When the sandbox blocks an action, find a non-destructive alternative rather than working around it. The stash/pop cycle can itself silently un-stage agit rm: a stash taken while a deletion is staged restores the deletion as unstaged onpop, so agit statusright after the cycle that shows the deleted file back as a plain unstaged change (rather than the staged deletion you left) has quietly lost the staging — re-stage it (git rm <path>again, orgit add -u) before committing. A stash/pop round-trip is never a no-op on a mixed add+rm working tree. -
Right worktree — pin one absolute path for the whole edit→commit→push sequence. Before branching, staging, or committing, confirm the worktree you are driving is the same one your edits landed in:
git -C <path> rev-parse --show-toplevelmust equal the path you edited, andgit -C <path> status --shortmust list those edits. Under symlinked or near-twin sibling worktrees — a parent dir that is itself a symlink (~/projects→~/Sync/projects), or look-alike names such as…/charlyvs…/<other-worktree>—cd-ing to the wrong sibling makesgit switch -c+git commitrun against a clean tree and report “nothing to commit”, silently landing nothing (or landing in the wrong repo). Never change the path spelling mid-sequence. An unexpected “nothing to commit” right after editing a file is the signature of this mistake — stop and re-verify--show-toplevelbefore retrying (blind retry is an R1 violation). -
The universal PR-gate — audit before any PR action, unconditionally. Before opening, updating, or merging any pull request, run the aggregate audit:
gh pr listacross every touched repo +git worktree list+ the live teammate/agent roster. This is a standing preflight, run first every time — never reached for only once something already looks off — because skipping it risks a duplicate PR for scope already covered in flight, a branch update from a stale worktree, or a merge over a still-running validator’s verdict. Full operational detail:/charly-internals:agents“The universal PR-gate”. -
Post-commit staging verification. After every commit, re-run
git status --short(expect it empty, or only unrelated untracked paths) andgit show --stat(confirm every intended file is actually listed) — a multi-pathgit addnaming several paths where one is mistyped or stale can commit only the files that did resolve whilegit commitstill succeeds, producing a commit that would not even compile.git show --statcatches that AFTER the fact, and it is the only post-commit check ON THE COMMITTING SIDE that sees a commit whose message describes an intent its diff does not carry — no gate that reads the tree can, because the mismatch is between the message and the tree, and the tree does not hold the message. It is NOT the only thing that can catch the class: any reader holding message and diff together — a review, a cross-repo reference sweep — catches it too, and catches the cases--statcannot, such as a mismatch that spans two repositories. -
Chain the edit to the commit so the failure cannot reach one. A script that edits and the
git add/git committhat follow are SEPARATE statements: a guard that aborts the edit does not stop the commit, which then lands under a message describing an edit it does not contain. Joining them —edit && git add … && git commit …— makes a failed edit unable to produce a commit at all. The defect occurred three times in one session; the chain was exercised ONCE, and that occurrence left no artifact by construction — so three is the evidence for the PROBLEM and one is the evidence for the REMEDY. Do not report them under a single “measured”. The chain is necessary, not sufficient:&&short-circuits on a non-zero exit, so an edit that fails LOUDLY cannot reach the commit, but a SILENT no-op edit — a pattern that matches nothing — exits 0 and lands the commit under a describing message exactly as before. That case still needs the post-commit read above; the chain closes only the aborting one. A rule held in a head is exactly as effective as a rule not held, unless something in the command line enforces it. -
git addis all-or-nothing: never name a path that no longer exists. A singlegit addwhose pathspec includes a vanished path fails the WHOLE add —fatal: pathspec '<old>' did not match any files, exit 128, nothing staged, including the paths that did resolve. Two shapes hit this, and both are silent because a PRIOR command already staged something, so the failedaddleavesgit statuslooking exactly as intended:- a deletion, where the removed file’s path is still named in the
add(the deletion was already staged) — lands a partial commit carrying only the deletion; - a rename plus a content edit, where
git mv old newis followed bygit add old new(themvalready staged the rename) — lands the rename with the content edit dropped.
Stage a rename+edit as
git add <new>alone, and a deletion by DIRECTORY or withgit add -u; never name the vanished path. This shape defeats the post-commit check above, which is why it needs stating separately:git show --statDOES list the file, asold.md => new.md | 0, so “every intended file is listed” passes. Presence is not the signal — the tells are the magnitude (| 0insertions on a commit meant to change content) and a leftoverM <new>ingit statusafterwards. Read the numbers and the post-commit status, not just the filenames, and confirm content directly withgit show HEAD:<new-path> | head -1whenever the edit is the point of the commit. - a deletion, where the removed file’s path is still named in the
-
Check-coverage is part of R10. The change must ship the test coverage that proves its functionality (
check:checks for new/changed layers & images, Go tests forcharlycode) AND the live run must have exercised it. A change whose new functionality has no test that would fail without it is not landable. -
Every repo is tagged at merge. The superproject, every
box/<distro>,plugins, anddocsall mintv<YYYY.DDD.HHMM>on their own merged HEAD — the tag marks the MERGE, decoupled from anycharly.ymlversion:schema field, so a repo needs nocharly.ymlto be tagged. The sole exception is the sdk contract repo, which tags under its own Go-module schemev0.<YYYYDDD>.<HHMM with all leading zeros stripped>(B2 step 0) — not an exemption but a hard Go-module requirement:v<YYYY.DDD.HHMM>is not a valid Go module version (semver forbids a leading-zero segment —0733→733— and amajor ≥ 2would force a/vNmodule-path suffix that breaks everyimport github.com/opencharly/sdk), so the strippedv0.<…>form is mandatory, not a choice. A skipped tag is a defect, not an exemption: the orchestrator verifies the tag landed after each merge (git ls-remote --tags origin v<VER>non-empty) and, if tag-on-merge skipped it, backfills it add-only on the merged HEAD (git tag -a v<VER> -m "<subject>" <merged-HEAD>+git push origin refs/tags/v<VER>) — tags are immutable, so a backfill only adds one, never moves an existing tag.
Reference Index
Section titled “Reference Index”| Topic | File |
|---|---|
| B1 (the two-step branch-per-change loop, concurrent landings, the cross-repo WIP landing sequence) and B4 (sync to upstream + prune) | references/branch-and-pr-loop.md |
B2 (multi-repo/multi-worktree coordination, per-module verification), B3 (agent teams in per-teammate worktrees), B6 (cross-repo @github landing), and B7 (multi-worktree landing + refresh, the canonical end-to-end) |
references/multi-repo-coordination.md |
| B5 (the fresh evaluator + fork/PR path, the two-gate autonomous-landing model), CalVer generation, post-landing cleanliness + report format, and the validation-FAILS recovery sequence | references/validator-and-calver.md |
Evidence discipline — provenance vs plausibility of a pasted gate, the three freshness surfaces (head / body / pasted output), positive-vs-negative claim decay, sweeping for claims a fix invalidated, the merged-tree gate for a BEHIND PR, source-and-regeneration as one cross-repo cutover, submodule pointers reverted by a non-conflicting merge, and why status-absence on a known head proves nothing |
references/evidence-and-freshness.md |
Cross-References
Section titled “Cross-References”- the project rulebook “Post-Execution Policies” — the mandate this skill operationalizes.
marketplace/internals/agents/pr-validator.md— the fresh evaluator’s full spec.opencharly/.github/scripts/branch-protection.sh— the sole organization-wide branch-protection apply/verify owner./charly-internals:cutover-policy— one-phase, atomic-commit, R10-at-the-end./charly-build:migrate—version:↔ tag coupling, per-merge tags, push order./charly-build:reconcile— cross-repo@githubpin alignment used by B6./charly-check:check— the check-coverage gate (R10) every change must satisfy./charly-internals:root-cause-analyzer— run on any FAIL before re-trying.
When to Use This Skill
Section titled “When to Use This Skill”Invoke before any git / gh action that commits, branches, pushes, opens a PR,
or drives the pr-validator merge/tag — and whenever syncing to upstream, applying
branch protection, or pruning branches/worktrees across the main repo and its
submodules.