The tool tells you what it would do in the current situation, you take a look and confirm that that's alright. Then you run it again without --dry-run, in a potentially different situation.
That's why I prefer Terraform's approach of having a "plan" mode. It doesn't just tell you what it would do but does so in the form of a plan it can later execute programmatically. Then, if any of the assumptions made during planning have changed, it can abort and roll back.
As a nice bonus, this pattern gives a good answer to the problem of having "if dry_run:" sprinkled everywhere: You have to separate the planning and execution in code anyway, so you can make the "just apply immediately" mode simply execute(plan()).
Not to take anything away from your comment but just to add a related story... the previous big AWS outage had an unforeseen race condition between their DNS planner vs DNS executor:
>[...] Right before this event started, one DNS Enactor experienced unusually high delays needing to retry its update on several of the DNS endpoints. As it was slowly working through the endpoints, several other things were also happening. First, the DNS Planner continued to run and produced many newer generations of plans. Second, one of the other DNS Enactors then began applying one of the newer plans and rapidly progressed through all of the endpoints. The timing of these events triggered the latent race condition. When the second Enactor (applying the newest plan) completed its endpoint updates, it then invoked the plan clean-up process, which identifies plans that are significantly older than the one it just applied and deletes them. At the same time that this clean-up process was invoked, the first Enactor (which had been unusually delayed) applied its much older plan to the regional DDB endpoint, overwriting the newer plan. The check that was made at the start of the plan application process, which ensures that the plan is newer than the previously applied plan, was stale by this time due to the unusually high delays in Enactor processing. [...]
previous HN thread: https://news.ycombinator.com/item?id=45677139
For something like in the article, I’m pretty sure a plan mode is overkill though.
Planning mode must involve making a domain specific language or data structure of some sort, which the execution mode will interpret and execute. I’m sure it would add a lot of complexity to a reporting tool where data is only collected once per day.
I've split the process into three parts:
1. Walk the filesystem, capture the current state of the files, and write out a plan to disk.
2. Make sure the state of the files from step 1 has not changed, then execute the plan. Capture the new state of the files. Additionally, log all operations to disk in a journal.
3. Validate that no data was lost or unexpectedly changed using the captured file state from steps 1 and 2. Manually look at the operations log (or dump it into an LLM) to make sure nothing looks off.
These three steps can be three separate scripts, or three flags to the same script.
Just last week I was writing a demo-focused Python file called `safetykit.py`, which has its first demo as this:
def praise_dryrun(dryrun: bool = True) -> None:
...
The snippet which demonstrates the plan-then-execute pattern I have is this: def gather(paths):
files = []
for pattern in paths:
files.extend(glob.glob(pattern))
return files
def execute(files):
for f in files:
os.remove(f)
files = gather([os.path.join(tmp_dir, "*.txt")])
if dryrun:
print(f"Would remove: {files}")
else:
execute(files)
I introduced dry-run at my company and I've been happy to see it spread throughout the codebase, because it's a coding practice that more than pays for itself.And how do you imagine doing that for the "rm" command?
Assuming our system is complex enough. I guess it sits between if dry_run and execute(plan()) in its complexity.
I've yet to have anyone mistakenly modify anything when they need to pass --commit, when I've repeatedly had people repeatedly accidentally modify stuff because they forgot --dry-run.
Put another way: your dry code should do everything up until the point that database writes / API calls / etc actually happen. Don't bail too early
Also, if I'm being honest, it's much better to use `--wet-run` for the production run than to ask people to run `--dry-run` for the test run. Less likely to accidentally fire off the real stuff.
It doesn't always work but sometimes I use `diff` to help with that. For example, if you have a complicated `sed` replacement that you plan to run on a file, you can use diff like this `diff -u <(echo "hello") <(echo "hello" | sed "s/hello/hi/g")` to help show what has changed.
I've written about the value of dry run too at: https://nickjanetakis.com/blog/cli-tools-that-support-previe...
Nowadays I still use the technique for a lot of the tools I make. I typically do a Terraform-like approach: create a plan, validate the plan, render the plan to the user, exit early if we're in dry-run mode, and apply the plan as the final step. Whether dry-run is enabled by default depends on the risk of the operations and who will be using the tool.
This makes it exceedingly clear what actions the tool will do. Plus it has the added benefit of being able to split the plan and apply steps into two separate steps. For example, you can create a plan, serialize it to JSON, store it (e.g. in VCS, Jira ticket, whatever) then apply it during a change window.
plan = createPlan()
print(plan.string())
if (dryRun){
return
}
plan.apply()And this works well if your CLI command is simply performing a single operation, e.g. call this REST API
But the moment it starts to do anything more complex: e.g. call API1, and then send the results of API1 to API2 – it becomes a lot more difficult
Of course, you can simulate what API1 is likely to have returned; but suddenly you have something a lot more complex and error-prone than just `if not dry_run:`
Quite handy.
This should always be included in any application that has a clear plan-then-execute flow, and it's definitely nice to have in other cases as well.
I've resorted to copying the entire directory (including the .git part) and then trying on the copy. The issue is that I'm working on a C++ program that has a few gigabytes of data.
"Do you really want to 'rm -rf /'? Type 'fiberglass' to proceed."
Anybody who rebooted a wrong server can say that this tool is brilliant.
Like "--dry-run" but for "reboot."
Otherwise it's not very wise to trust the application on what should be a deputy responsibility.
Nowadays I'd probably use OverlayFS (or just Docker) to see what the changes would be, without ever risking the original FS.
I prefer the inverse, better, though. Default off, and then add `--commit` or `--just-do-it` to make it actually run.
Sounds like a case for the state machine pattern
It's even more relevant now that you can get the LLMs/CLI agents to use your deterministic CLI tools.
(Don't try it in a terminal window, though)
--really
--really-really
--yolo
Not to be overly critical (I think it's great OP found value in adding and using --dry-run), but I am willing to bet that this was a suggestion/addition from a coding agent (and most likely Claude code/Opus). Having used it myself to build various CLI tools in different languages, it almost always creates that option when iterating on CLIs. To the point where it's almost a tell. I wonder if we're entering a moment of convergence where all the tools will have similar patterns/options because they are similarly written by agents.