<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rob Bulkley</title>
    <description>The latest articles on DEV Community by Rob Bulkley (@mrwizard94).</description>
    <link>https://dev.to/mrwizard94</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4022397%2Fe22cfff7-2f89-412f-9975-362cf512a0bc.png</url>
      <title>DEV Community: Rob Bulkley</title>
      <link>https://dev.to/mrwizard94</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mrwizard94"/>
    <language>en</language>
    <item>
      <title>What my project bootstrapper does that uv init doesn't (and why I stopped using cookiecutter)</title>
      <dc:creator>Rob Bulkley</dc:creator>
      <pubDate>Thu, 09 Jul 2026 08:24:10 +0000</pubDate>
      <link>https://dev.to/mrwizard94/what-my-project-bootstrapper-does-that-uv-init-doesnt-and-why-i-stopped-using-cookiecutter-3cpd</link>
      <guid>https://dev.to/mrwizard94/what-my-project-bootstrapper-does-that-uv-init-doesnt-and-why-i-stopped-using-cookiecutter-3cpd</guid>
      <description>&lt;h2&gt;
  
  
  The 20-minute tax
&lt;/h2&gt;

&lt;p&gt;Every new Python project, same liturgy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
.venv&lt;span class="se"&gt;\S&lt;/span&gt;cripts&lt;span class="se"&gt;\a&lt;/span&gt;ctivate          &lt;span class="c"&gt;# I'm on Windows, hold your condolences&lt;/span&gt;
&lt;span class="c"&gt;# go find the .gitignore from the last project, copy it, wonder if it was the good one&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;requests python-dotenv rich pytest   &lt;span class="c"&gt;# the usual suspects&lt;/span&gt;
git init
git branch &lt;span class="nt"&gt;-M&lt;/span&gt; main
git add &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Initial commit"&lt;/span&gt;
&lt;span class="c"&gt;# alt-tab to browser, create the GitHub repo, copy the URL&lt;/span&gt;
git remote add origin https://github.com/me/thing.git
git push &lt;span class="nt"&gt;-u&lt;/span&gt; origin main
&lt;span class="c"&gt;# ...now what was I actually going to build?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of this is hard. All of it is friction. And I noticed something worse than the lost 20 minutes: I was procrastinating on &lt;em&gt;starting things&lt;/em&gt; because of it. For a side-project developer with a family and maybe eight good hours a week, the setup tax isn't an annoyance - it's a filter that kills small ideas before they exist.&lt;/p&gt;

&lt;p&gt;So I did what we all do: wrote a script. Then, because the script was genuinely useful, I did the less common thing: turned it into a real tool with tests and opinions. This post is about the three design decisions that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Project setup" is three different jobs
&lt;/h2&gt;

&lt;p&gt;We say "setup" like it's one thing. It's three:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Environment&lt;/strong&gt; - venv, dependencies, interpreter wiring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plumbing&lt;/strong&gt; - git init, branch naming, first commit, the remote, hooks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opinions&lt;/strong&gt; - layout, license, pyproject, test scaffold, what "done" looks like.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;uv init&lt;/code&gt; does job 1 beautifully, and if that's all you need, use it and close this tab. Cookiecutter does job 3 - &lt;em&gt;if&lt;/em&gt; you enjoy maintaining Jinja templates in a separate repo, which past-me apparently thought he would. (Narrator: he did not. The template rotted in four months.)&lt;/p&gt;

&lt;p&gt;Almost nothing does job 2, which is strange, because job 2 is the part that's pure ritual - there are no interesting decisions in &lt;code&gt;git branch -M main&lt;/code&gt;. RepoForge does all three in one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python repoforge.py my-api &lt;span class="nt"&gt;--profile&lt;/span&gt; fastapi &lt;span class="nt"&gt;--pre-commit&lt;/span&gt; &lt;span class="nt"&gt;--create-remote&lt;/span&gt; &lt;span class="nt"&gt;--push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixty seconds later: venv, curated deps, &lt;code&gt;src/&lt;/code&gt; layout with a running &lt;code&gt;/health&lt;/code&gt; endpoint, README, license, &lt;code&gt;pyproject.toml&lt;/code&gt;, a passing pytest suite, ruff+black pre-commit hooks, and a private GitHub repo with the first commit already pushed (via the &lt;code&gt;gh&lt;/code&gt; CLI when you have it, plain &lt;code&gt;remote add&lt;/code&gt; when you don't).&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraints I refused to break
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One file, stdlib only.&lt;/strong&gt; A bootstrapper with its own dependency tree is a joke that writes itself. RepoForge is a single Python file you can read in one coffee and edit to taste. If you hate one of my opinions, it's &lt;em&gt;right there&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotent, or it's a landmine.&lt;/strong&gt; The first version of this script - the private one I used for a year - died if you ran it twice: &lt;code&gt;git remote add origin&lt;/code&gt; fails when origin exists, and &lt;code&gt;git commit&lt;/code&gt; fails when there's nothing staged. Fine for me, who remembered. Unacceptable for anyone else. The rule now: every mutation goes through one gate that checks reality first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Write a file unless it already exists (respected unless --force).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;force&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;skipped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  -- &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: exists, left untouched (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dry_run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-running RepoForge on a project you've customized will never eat your README. It tells you what it skipped and moves on. Same for git state: existing repo, existing remote, nothing-to-commit - detected, respected, reported.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--dry-run&lt;/code&gt; that provably touches nothing.&lt;/strong&gt; Because every file write and subprocess call goes through that one &lt;code&gt;Runner&lt;/code&gt; object, dry-run isn't a best-effort flag sprinkled through the code - it's a property of the architecture. There's a test that runs the entire pipeline in dry-run mode and asserts the target directory &lt;em&gt;doesn't exist afterward&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Profiles, not templates
&lt;/h2&gt;

&lt;p&gt;Here's the cookiecutter lesson: a template is a &lt;em&gt;repository&lt;/em&gt; you maintain. A profile is ten lines of &lt;em&gt;data&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fastapi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Web API: FastAPI + Uvicorn + httpx test client, src layout.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;packages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fastapi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uvicorn[standard]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pydantic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;httpx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pytest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ruff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;src_layout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven of them ship in v1: &lt;code&gt;minimal&lt;/code&gt;, &lt;code&gt;general&lt;/code&gt;, &lt;code&gt;cli&lt;/code&gt; (Typer), &lt;code&gt;fastapi&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;, &lt;code&gt;automation&lt;/code&gt;, and the one I actually built this for - &lt;code&gt;ml-experiment&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I do a lot of measured AI-efficiency experiments on my own hardware, and the failure mode of hobby research isn't bad math - it's &lt;em&gt;bad bookkeeping&lt;/em&gt;. "I think run 14 was the good one" is not a methodology. So the &lt;code&gt;ml-experiment&lt;/code&gt; profile scaffolds the discipline, not just the folders: an &lt;code&gt;EXPERIMENT_LOG.md&lt;/code&gt; where every entry needs a falsifiable hypothesis and a measured result, and a &lt;code&gt;CLAIMS.md&lt;/code&gt; registry where every claim carries its evidence class - &lt;strong&gt;Established / Supported / Working Hypothesis / Falsified&lt;/strong&gt; - and, crucially, its falsification condition. Failed experiments stay in the log, because a falsified claim is a result, not an embarrassment.&lt;/p&gt;

&lt;p&gt;That file layout has survived twenty-plus iteration cycles of real work on my machines. It's the part of this tool you genuinely can't get from a generic template, because it's not a guess about how research should work - it's scar tissue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What testing your own tool teaches you
&lt;/h2&gt;

&lt;p&gt;RepoForge ships with its own 23-test pytest suite, and writing it was humbling in the specific way testing always is. The pure functions were fine. The lies were all in the seams: the idempotency guarantees I &lt;em&gt;believed&lt;/em&gt; I had implemented versus the ones the integration test could actually demonstrate. The test that builds a real repo, edits the README, re-runs the whole pipeline, and asserts the edit survived - that test failed the first time I ran it, and the fix is why I trust the tool enough to sell it.&lt;/p&gt;

&lt;p&gt;If you take one thing from this post that isn't about bootstrapping: the one-gate-for-all-mutations pattern (&lt;code&gt;Runner&lt;/code&gt; above) is what made both &lt;code&gt;--dry-run&lt;/code&gt; and the idempotency tests nearly free. Structure buys you the guarantees; discipline alone doesn't scale past one author.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RepoForge Lite&lt;/strong&gt; - free, MIT, one file, does the whole basic job (venv + deps + git + remote): &lt;strong&gt;&lt;a href="https://github.com/MrWizard94-Compile/repoforge" rel="noopener noreferrer"&gt;github.com/MrWizard94-Compile/repoforge&lt;/a&gt;&lt;/strong&gt;. Not crippleware; it will do that job forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RepoForge Pro&lt;/strong&gt; - all seven profiles, full scaffolding, pre-commit, &lt;code&gt;gh&lt;/code&gt; integration, the test suite, and a personal license: &lt;strong&gt;&lt;a href="https://wpaistudio.gumroad.com/l/repoforge" rel="noopener noreferrer"&gt;wpaistudio.gumroad.com/l/repoforge&lt;/a&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd genuinely like to know: what's in &lt;em&gt;your&lt;/em&gt; project-start ritual that I haven't automated? The profiles exist because mine kept diverging by project type - if yours diverges differently, that's the next profile.&lt;/p&gt;

</description>
      <category>python</category>
      <category>productivity</category>
      <category>devtools</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
