Skip to main content

Command Palette

Search for a command to run...

A Git Commit Is Not Just a Change. It's a Command.

Updated
6 min read

A Git Commit Is Not Just a Change. It's a Command.

Here's a scenario that plays out in a lot of platform teams.

Your CI/CD pipeline runs kubectl apply as a job step. It exits zero. The pipeline goes green. Deployment successful — or so the dashboard says. But nobody actually knows if the manifest that was applied was valid, if the pods came up healthy, or if the service is responding. So you add another job to check. And another to verify the rollout. And a retry mechanism for when the check fails. And now your pipeline is a sequence of shell scripts duct-taped together, each one trying to compensate for the fact that the previous step had no way to confirm its own outcome.

And when something goes wrong — which it does — you're cross-referencing pipeline logs, Kubernetes events, and Slack messages trying to reconstruct what actually happened and in what order.

This is the problem that GitOps solves. But the usual explanation — "Git is your source of truth" — doesn't tell you why that works, or why it's architecturally sound rather than just a convention someone decided to follow.

The Command pattern does.


The problem

Pipelines that apply changes directly are fast and simple. They're also invisible.

When a CI/CD pipeline runs kubectl apply or terraform apply directly, the action happens and disappears. You have a log entry somewhere, maybe. You have the pipeline run in your CI system, if you know where to look. But the intent — what was supposed to happen, who requested it, what the expected state was — isn't captured anywhere durable.

This creates a class of problems that compound over time. Drift between what's in your repo and what's actually running. No reliable way to roll back a change without re-running a pipeline in reverse. Audit trails that live in your CI system instead of with the infrastructure they describe. And a mental model problem: nobody has a single place to look to understand the current intended state of the system.

The deeper issue is that executing and recording are coupled. The change happens and the record of the change is scattered across logs, pipeline runs, and people's memory. When something goes wrong, you're reconstructing history instead of reading it.


Why it hurts

Imagine you need to answer a simple question: what changed in the production cluster between Tuesday and Thursday?

Without GitOps, that question requires cross-referencing CI pipeline runs, Kubernetes audit logs, Slack messages, and whoever was on call. Each of those sources has partial information. None of them has the full picture. And if the change was a manual kubectl apply that someone ran from their laptop, it might not appear anywhere at all.

Now multiply that across ten services, three environments, and a team of fifteen. The cognitive overhead of understanding the current state of your platform becomes a full-time job. And when incidents happen — which they do — the time you spend reconstructing what changed is time you're not spending fixing the problem.


What the pattern says

The Command pattern says: instead of executing an action directly, encapsulate it as an object — something that can be stored, passed around, queued, undone, or audited — and separate the act of defining the command from the act of executing it.

In software, this pattern shows up in undo/redo systems, job queues, and transaction logs. The key insight is that by making the command a first-class thing — not just a function call that disappears — you gain properties you couldn't have otherwise: history, reversibility, auditability, and the ability to replay or defer execution.

In platform engineering, a Git commit in a GitOps repository is exactly this. It's not just a text change. It's a persisted command — with an author, a timestamp, a description of intent, and the ability to be reversed with a git revert. The commit is the command object. ArgoCD or Flux is the executor that reads that command and applies it to the cluster.

The separation is the point. The commit and the apply are two distinct steps, with Git in between acting as a durable, auditable command queue.


Where you see this in practice

ArgoCD and Flux are Command pattern executors. Their job is to watch a Git repository — the command queue — and continuously reconcile the cluster state against the commands stored there. When a new commit arrives, they execute it. When the cluster drifts from the desired state, they re-execute the last command to bring it back.

This is why GitOps gives you drift detection almost for free. The executor is always comparing what the command says against what's actually running. Drift is just a command that hasn't been fully executed yet.

Terraform with remote state applies the same idea to infrastructure. The .tf files are the commands. The state file is the record of what's been executed. terraform plan shows you what the command would do before you run it. terraform apply executes it. The separation between plan and apply is the Command pattern in action.

Pull Request workflows add another layer. Before a command is committed to the queue, it goes through review. The PR is the command in draft — visible, discussable, and rejectable before it's persisted. Merge is the moment the command becomes official. This is why "GitOps with PR-based workflows" gives you change management essentially for free, without a separate tool.

Argo Workflows and Tekton extend this to pipeline definitions. The pipeline itself is a command — defined declaratively, stored in Git, executed by the platform. You're not running imperative scripts. You're defining commands that the platform knows how to execute.


What changes when you think this way

The Command framing answers a question that comes up constantly: why GitOps instead of just running kubectl apply in a pipeline?

The answer isn't "because GitOps is best practice." The answer is that running kubectl apply in a pipeline couples execution to the pipeline run. The command and its execution happen at the same time and leave no durable record tied to the infrastructure itself. GitOps decouples them — the command lives in Git, the executor applies it, and the two can operate independently.

That decoupling gives you things that are very hard to retrofit:

  • Auditability — every change has an author, a timestamp, and a description, tied to the infrastructure it describes
  • Reversibility — rolling back is a git revert, not a re-run of a pipeline in the right order with the right parameters
  • Drift detection — the executor knows what the command says and can tell you when reality disagrees
  • Separation of concerns — the team that defines what should be running is decoupled from the mechanism that makes it run

Once you see a Git commit as a command object rather than just a file change, the architecture of GitOps stops being a convention and starts being a deliberate design choice with clear properties. And that makes it much easier to explain, defend, and extend.


Is your team still running direct applies in pipelines? Or have you made the jump to GitOps? Either way — what's the hardest part of the transition? Hit reply at blog@parraletz.dev.