<?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: Steven Keil</title>
    <description>The latest articles on DEV Community by Steven Keil (@steven_keil_cb87a560f87f0).</description>
    <link>https://dev.to/steven_keil_cb87a560f87f0</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%2F4206469%2Fb1c9050b-39aa-4574-bcf6-545506e45762.jpg</url>
      <title>DEV Community: Steven Keil</title>
      <link>https://dev.to/steven_keil_cb87a560f87f0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/steven_keil_cb87a560f87f0"/>
    <language>en</language>
    <item>
      <title>Finding Email Attachments Yourself with IMAP Search (and When You'd Rather Not)</title>
      <dc:creator>Steven Keil</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:16:52 +0000</pubDate>
      <link>https://dev.to/steven_keil_cb87a560f87f0/finding-email-attachments-yourself-with-imap-search-and-when-youd-rather-not-38pj</link>
      <guid>https://dev.to/steven_keil_cb87a560f87f0/finding-email-attachments-yourself-with-imap-search-and-when-youd-rather-not-38pj</guid>
      <description>&lt;p&gt;If you only need last month's receipts, you don't need a tool. Gmail's search operators and a few lines of Python will get you there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with Gmail search syntax
&lt;/h2&gt;

&lt;p&gt;In the Gmail search bar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;has:attachment filename:pdf after:2026/09/01 before:2026/10/01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combine &lt;code&gt;from:&lt;/code&gt;, &lt;code&gt;subject:&lt;/code&gt; and &lt;code&gt;has:attachment&lt;/code&gt; to narrow things down, e.g. &lt;code&gt;from:billing@example.com has:attachment&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pull attachments via IMAP
&lt;/h2&gt;

&lt;p&gt;If you want them saved locally without clicking through each email, Gmail supports IMAP with an app password (this requires 2-step verification to be enabled first). Note that some company (Workspace) accounts block app passwords through an admin policy. A minimal script:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;imaplib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="n"&gt;mail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;imaplib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IMAP4_SSL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;imap.gmail.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;you@gmail.com&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;your-app-password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Gmail search extension via IMAP
&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;X-GM-RAW &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;has:attachment after:2026/09/01&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;num&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(RFC822)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message_from_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_content_disposition&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attachment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_filename&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;safe_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&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;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# never trust a path from an email
&lt;/span&gt;                &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;safe_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&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;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decode&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Saved:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;mail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for Gmail and any other IMAP mailbox (Outlook, Yahoo, iCloud, GMX, web.de) if you swap the host and use that provider's IMAP settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few gotchas before you rely on this for anything recurring
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Re-running the script will save the same file twice unless you add your own dedupe logic.&lt;/li&gt;
&lt;li&gt;Tiny inline images (signature logos) show up as "attachments" too and will clutter your folder.&lt;/li&gt;
&lt;li&gt;Two attachments with the same name overwrite each other.&lt;/li&gt;
&lt;li&gt;If you want things sorted into dated folders automatically, or want to keep it running in the background so new invoices get saved the moment they arrive, you'll need to build that layer yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you'd rather skip writing and maintaining that part
&lt;/h2&gt;

&lt;p&gt;I made a small tool that does exactly this. It filters by sender, subject, Gmail search, date and file type, skips signature images and tiny files, never saves the same file twice, sorts everything into dated folders like &lt;code&gt;2026/10/2026-10-01_Invoice 42.pdf&lt;/code&gt;, and has a watch mode so it keeps running. It works with Gmail (app password and 2-step verification) and any IMAP mailbox, and is read-only by default.&lt;/p&gt;

&lt;p&gt;It is mine and it is paid, 12 EUR: &lt;a href="https://skeilster.gumroad.com/l/gmail-attachment-saver" rel="noopener noreferrer"&gt;https://skeilster.gumroad.com/l/gmail-attachment-saver&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>email</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Finding Exact Duplicate Photos Without Installing Anything</title>
      <dc:creator>Steven Keil</dc:creator>
      <pubDate>Sun, 25 Oct 2026 12:55:53 +0000</pubDate>
      <link>https://dev.to/steven_keil_cb87a560f87f0/finding-exact-duplicate-photos-without-installing-anything-acn</link>
      <guid>https://dev.to/steven_keil_cb87a560f87f0/finding-exact-duplicate-photos-without-installing-anything-acn</guid>
      <description>&lt;p&gt;If your photo library has grown out of control, there's a good chance a chunk of it is exact duplicates: the same file copied during a backup, a sync, or an import you ran twice. Before reaching for any tool, it's worth knowing you can find these with something already on your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quick way: sha256sum
&lt;/h2&gt;

&lt;p&gt;On Linux (GNU coreutils), you can hash every file in a folder and group the ones with identical hashes. Identical hash means identical content, byte for byte.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s2"&gt;"*.jpg"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s2"&gt;"*.jpeg"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s2"&gt;"*.png"&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; hashes.txt
&lt;span class="nb"&gt;sort &lt;/span&gt;hashes.txt | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-w64&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line walks the current folder recursively and writes one hash per image into &lt;code&gt;hashes.txt&lt;/code&gt;. The second sorts by hash and prints every line that belongs to a group of identical files. &lt;code&gt;uniq -w64 -D&lt;/code&gt; compares only the first 64 characters (the hash itself).&lt;/p&gt;

&lt;p&gt;On macOS, use &lt;code&gt;shasum -a 256&lt;/code&gt; instead of &lt;code&gt;sha256sum&lt;/code&gt;, and install GNU coreutils (&lt;code&gt;brew install coreutils&lt;/code&gt;) to get a &lt;code&gt;uniq&lt;/code&gt; that supports &lt;code&gt;-w&lt;/code&gt;. It is then available as &lt;code&gt;guniq&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this gets you, and what it doesn't
&lt;/h3&gt;

&lt;p&gt;This approach is great for exact duplicates: the same file, same bytes, maybe just renamed or moved. It won't catch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same photo re-exported at a different resolution&lt;/li&gt;
&lt;li&gt;A slightly recompressed JPEG&lt;/li&gt;
&lt;li&gt;A screenshot vs. the original image it was taken from&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For those "similar but not identical" cases you need perceptual comparison, not a cryptographic hash. That is a different problem and outside the scope of a one-liner.&lt;/p&gt;

&lt;p&gt;Also worth noting: the commands above only report duplicates. They don't move or delete anything, which is a good thing. You don't want a script silently deleting files based on a hash match without you checking first.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you outgrow the one-liner
&lt;/h2&gt;

&lt;p&gt;The commands above work fine for one folder and exact matches. It starts to get annoying once you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Thousands of photos across many nested folders&lt;/li&gt;
&lt;li&gt;Near-duplicates (same photo, different edit, export or resolution)&lt;/li&gt;
&lt;li&gt;A need for a report you can review before anything is touched&lt;/li&gt;
&lt;li&gt;A desire to not delete files outright, just quarantine them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the situation I built &lt;strong&gt;Duplicate Photo Finder Pro&lt;/strong&gt; for. It is mine and it is paid. It scans folders for exact duplicates (SHA-256, the same method as above) and also for similar images via perceptual hashing with an adjustable threshold. It runs in dry-run mode first, writes a CSV/JSON report, and moves duplicates to a quarantine folder instead of deleting them, keeping whichever copy you choose (oldest, largest or highest resolution) as the original. It runs fully offline, with no cloud upload.&lt;/p&gt;

&lt;p&gt;Duplicate Photo Finder Pro, 12 EUR: &lt;a href="https://skeilster.gumroad.com/l/nejwxp" rel="noopener noreferrer"&gt;https://skeilster.gumroad.com/l/nejwxp&lt;/a&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>bash</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Generating Changelogs from Git Log Between Two Tags</title>
      <dc:creator>Steven Keil</dc:creator>
      <pubDate>Sun, 25 Oct 2026 12:55:35 +0000</pubDate>
      <link>https://dev.to/steven_keil_cb87a560f87f0/generating-changelogs-from-git-log-between-two-tags-8g5</link>
      <guid>https://dev.to/steven_keil_cb87a560f87f0/generating-changelogs-from-git-log-between-two-tags-8g5</guid>
      <description>&lt;p&gt;If you've ever had to write release notes by scrolling through &lt;code&gt;git log&lt;/code&gt;, you know the drill: squinting at commit messages, manually sorting "fix" from "feat," and copy-pasting into a Markdown file by hand.&lt;/p&gt;

&lt;p&gt;Here's a quick way to make that less painful using only &lt;code&gt;git log&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Get the raw commits between two tags
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log v1.2.0..v1.3.0 &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;format:&lt;span class="s2"&gt;"%s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you every commit subject line between &lt;code&gt;v1.2.0&lt;/code&gt; and &lt;code&gt;v1.3.0&lt;/code&gt;. You can also use two commit hashes instead of tags if you don't tag releases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Group by conventional commit prefix
&lt;/h2&gt;

&lt;p&gt;If your commits follow a loose convention (&lt;code&gt;feat:&lt;/code&gt;, &lt;code&gt;fix:&lt;/code&gt;, &lt;code&gt;chore:&lt;/code&gt;, &lt;code&gt;docs:&lt;/code&gt;), you can split them out with a bit of shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log v1.2.0..v1.3.0 &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;format:&lt;span class="s2"&gt;"%s"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"^feat"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; features.txt
git log v1.2.0..v1.3.0 &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;format:&lt;span class="s2"&gt;"%s"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"^fix"&lt;/span&gt;  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; fixes.txt
git log v1.2.0..v1.3.0 &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;format:&lt;span class="s2"&gt;"%s"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"^chore"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; chores.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then concatenate them under headings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Features&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; feat: add dark mode toggle

&lt;span class="gu"&gt;## Fixes&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; fix: correct timezone offset in export
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Where this breaks down
&lt;/h2&gt;

&lt;p&gt;This works fine for a handful of commits. It gets tedious once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Commits don't follow the convention consistently&lt;/li&gt;
&lt;li&gt;You have dozens of commits between tags&lt;/li&gt;
&lt;li&gt;You want the same grouped Markdown format every release, without rebuilding the grep pipeline each time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the exact gap I built a small tool for.&lt;/p&gt;

&lt;h2&gt;
  
  
  My own product (it is mine and it is paid)
&lt;/h2&gt;

&lt;p&gt;I made a &lt;strong&gt;Markdown Changelog Generator&lt;/strong&gt; that reads the git log between two tags or commits and writes out grouped Markdown automatically, so you don't have to rebuild the grep-and-concatenate pipeline above every release.&lt;/p&gt;

&lt;p&gt;It's 12 EUR: &lt;a href="https://skeilster.gumroad.com/l/hdsfn" rel="noopener noreferrer"&gt;https://skeilster.gumroad.com/l/hdsfn&lt;/a&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>changelog</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to clean up merged git branches safely (and the squash-merge trap)</title>
      <dc:creator>Steven Keil</dc:creator>
      <pubDate>Sun, 25 Oct 2026 12:55:01 +0000</pubDate>
      <link>https://dev.to/steven_keil_cb87a560f87f0/how-to-clean-up-merged-git-branches-safely-and-the-squash-merge-trap-j55</link>
      <guid>https://dev.to/steven_keil_cb87a560f87f0/how-to-clean-up-merged-git-branches-safely-and-the-squash-merge-trap-j55</guid>
      <description>&lt;p&gt;If you work on a few repos for a year, your local branch list turns into a graveyard: &lt;code&gt;feature/login-fix&lt;/code&gt;, &lt;code&gt;tmp-test&lt;/code&gt;, &lt;code&gt;hotfix-2&lt;/code&gt;, and fifteen more nobody remembers. Cleaning up is easy, but only if you avoid the commands that delete work by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. See what is already merged
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git switch main
git pull
git branch &lt;span class="nt"&gt;--merged&lt;/span&gt; main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything in this list has its commits in &lt;code&gt;main&lt;/code&gt; already. Deleting these branches loses nothing. Ignore &lt;code&gt;main&lt;/code&gt; itself and any branch you are currently on.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Delete with the safe flag
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git branch &lt;span class="nt"&gt;-d&lt;/span&gt; old-feature
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lowercase &lt;code&gt;-d&lt;/code&gt; refuses to delete a branch that is not fully merged. The uppercase &lt;code&gt;-D&lt;/code&gt; deletes regardless. Use &lt;code&gt;-d&lt;/code&gt; by default and only reach for &lt;code&gt;-D&lt;/code&gt; when you have looked at the branch and know you do not need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Delete many at once, with a preview
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git branch &lt;span class="nt"&gt;--merged&lt;/span&gt; main | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vE&lt;/span&gt; &lt;span class="s1"&gt;'^\*|main|develop'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; to-delete.txt
&lt;span class="nb"&gt;cat &lt;/span&gt;to-delete.txt          &lt;span class="c"&gt;# read it first&lt;/span&gt;
xargs git branch &lt;span class="nt"&gt;-d&lt;/span&gt; &amp;lt; to-delete.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adjust the &lt;code&gt;grep -v&lt;/code&gt; pattern to keep your protected branches. The text file is the preview: nothing is deleted until you have read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The squash-merge trap
&lt;/h2&gt;

&lt;p&gt;If your team squash-merges pull requests, &lt;code&gt;git branch --merged&lt;/code&gt; will not list those branches, because the squashed commit is a new commit. Git cannot tell they are merged. Check the pull request page instead, and only then delete with &lt;code&gt;-D&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Remote branches
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git fetch &lt;span class="nt"&gt;--prune&lt;/span&gt;
git branch &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;--merged&lt;/span&gt; origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--prune&lt;/code&gt; removes local references to branches that were already deleted on the remote. Deleting remote branches (&lt;code&gt;git push origin --delete name&lt;/code&gt;) affects your whole team, so ask first.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A habit that avoids most of this
&lt;/h2&gt;

&lt;p&gt;Delete the branch right after the pull request is merged. Most hosting platforms have a setting to do that automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you do this often
&lt;/h2&gt;

&lt;p&gt;I got tired of repeating these steps across repos and wrote a small Python tool that previews the list and then deletes the merged branches. It is mine and it is paid (9 EUR): &lt;a href="https://skeilster.gumroad.com/l/uvpwln" rel="noopener noreferrer"&gt;https://skeilster.gumroad.com/l/uvpwln&lt;/a&gt;. Everything above works without it.&lt;/p&gt;

&lt;p&gt;Which edge cases cause you trouble: squash merges, protected branches, something else?&lt;/p&gt;

</description>
      <category>git</category>
      <category>tutorial</category>
      <category>productivity</category>
      <category>beginners</category>
    </item>
    <item>
      <title>7 things that break a Shopify product CSV import (and how to catch them before you upload)</title>
      <dc:creator>Steven Keil</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:33:25 +0000</pubDate>
      <link>https://dev.to/steven_keil_cb87a560f87f0/7-things-that-break-a-shopify-product-csv-import-and-how-to-catch-them-before-you-upload-354j</link>
      <guid>https://dev.to/steven_keil_cb87a560f87f0/7-things-that-break-a-shopify-product-csv-import-and-how-to-catch-them-before-you-upload-354j</guid>
      <description>&lt;p&gt;If you have ever uploaded a product CSV to Shopify and ended up with a wall of red error messages, or worse, an import that "succeeded" but left you with wrong prices and missing images, this list is for you. These are the problems that cause most failed or silently wrong imports, plus a quick way to check each one yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Prices in the wrong format
&lt;/h2&gt;

&lt;p&gt;Spreadsheet tools in many locales write &lt;code&gt;1.299,00&lt;/code&gt; or &lt;code&gt;12,5&lt;/code&gt;. Shopify expects a plain number with a dot: &lt;code&gt;1299.00&lt;/code&gt;, &lt;code&gt;12.50&lt;/code&gt;. A currency symbol (&lt;code&gt;$12.50&lt;/code&gt;) is also a classic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; sort the &lt;code&gt;Variant Price&lt;/code&gt; column and look for commas, symbols and letters.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Missing prices
&lt;/h2&gt;

&lt;p&gt;A row without &lt;code&gt;Variant Price&lt;/code&gt; is either rejected or imported with a price you did not intend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; filter for empty cells in that column.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Duplicate SKUs
&lt;/h2&gt;

&lt;p&gt;Two variants with the same &lt;code&gt;Variant SKU&lt;/code&gt; confuse inventory tracking and later syncs with other tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; use a "remove duplicates" or COUNTIF check on the SKU column. Empty SKUs are fine; repeated ones are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Inventory that is not a whole number
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Variant Inventory Qty&lt;/code&gt; must be an integer. &lt;code&gt;3.5&lt;/code&gt;, &lt;code&gt;-2&lt;/code&gt; or &lt;code&gt;"ten"&lt;/code&gt; will cause trouble.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; filter for values containing a dot or a minus sign.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Handles with spaces or capitals
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Handle&lt;/code&gt; becomes part of the product URL. Use lowercase letters, numbers and dashes: &lt;code&gt;blue-shirt&lt;/code&gt;, not &lt;code&gt;Blue Shirt&lt;/code&gt;. Rows that belong to the same product (extra variants and images) must repeat the exact same handle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; the same handle should never appear with two different titles.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Broken image URLs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Image Src&lt;/code&gt; has to be a full, publicly reachable &lt;code&gt;https://&lt;/code&gt; link. A link to a file on your own computer, a missing &lt;code&gt;https://&lt;/code&gt;, or a link behind a login does not work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; paste a few URLs into a private browser window.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. TRUE/FALSE columns that are not TRUE/FALSE
&lt;/h2&gt;

&lt;p&gt;Columns such as &lt;code&gt;Published&lt;/code&gt; expect &lt;code&gt;TRUE&lt;/code&gt; or &lt;code&gt;FALSE&lt;/code&gt;. Values like &lt;code&gt;yes&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt; or &lt;code&gt;true&lt;/code&gt; with a trailing space are a common source of "why is my product hidden?".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; filter the column and look at the distinct values.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small habit that saves hours
&lt;/h2&gt;

&lt;p&gt;Before every big import, import &lt;strong&gt;three products first&lt;/strong&gt; and look at them in the admin. Only then upload the full file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do it automatically
&lt;/h2&gt;

&lt;p&gt;Doing these seven checks by hand on a file with a few thousand rows is tedious, so I wrote a small checker. It fixes the safe things (spaces, handle format, price format, TRUE/FALSE) and only reports the things that need a human decision (missing prices, duplicate SKUs, bad inventory, broken image URLs). I offer it as a paid service for 15 € per file (up to 2,000 rows), without any login to your store: &lt;a href="https://skeilster.gumroad.com/l/shopify-csv-check" rel="noopener noreferrer"&gt;https://skeilster.gumroad.com/l/shopify-csv-check&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Disclosure: that service is mine. The list above works fine without it.&lt;/p&gt;

&lt;p&gt;What else has bitten you in a CSV import? I am collecting cases to cover in the checker.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>csv</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
