<?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: Evan</title>
    <description>The latest articles on DEV Community by Evan (@thebguy).</description>
    <link>https://dev.to/thebguy</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%2F4069077%2F233c719d-e708-4fc6-a41c-5bdd6f5642b2.jpg</url>
      <title>DEV Community: Evan</title>
      <link>https://dev.to/thebguy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thebguy"/>
    <language>en</language>
    <item>
      <title>Will this merge conflict? Find out without merging</title>
      <dc:creator>Evan</dc:creator>
      <pubDate>Mon, 31 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/thebguy/will-this-merge-conflict-find-out-without-merging-2hp5</link>
      <guid>https://dev.to/thebguy/will-this-merge-conflict-find-out-without-merging-2hp5</guid>
      <description>&lt;p&gt;You're about to merge a long-running branch and you'd like to know, first,&lt;br&gt;
whether it's going to hurt. Not "did it hurt" — &lt;em&gt;will&lt;/em&gt; it. Ten files or two&lt;br&gt;
hundred, five minutes or an afternoon, safe to do now or better after lunch.&lt;/p&gt;

&lt;p&gt;Git has known the answer the whole time. It just isn't the answer you get from&lt;br&gt;
running &lt;code&gt;git merge&lt;/code&gt; and reading the wreckage.&lt;/p&gt;
&lt;h2&gt;
  
  
  The usual answer costs you your working tree
&lt;/h2&gt;

&lt;p&gt;The standard trick is to merge without committing and look:&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;$ &lt;/span&gt;git merge &lt;span class="nt"&gt;--no-commit&lt;/span&gt; &lt;span class="nt"&gt;--no-ff&lt;/span&gt; feature
Auto-merging shared.txt
CONFLICT &lt;span class="o"&gt;(&lt;/span&gt;content&lt;span class="o"&gt;)&lt;/span&gt;: Merge conflict &lt;span class="k"&gt;in &lt;/span&gt;shared.txt
Automatic merge failed&lt;span class="p"&gt;;&lt;/span&gt; fix conflicts and &lt;span class="k"&gt;then &lt;/span&gt;commit the result.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That did tell you. It also rewrote your working tree to tell you:&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;$ &lt;/span&gt;git status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;
UU shared.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you're sitting in a conflicted merge state you didn't want, and you have to&lt;br&gt;
back out of it:&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;$ &lt;/span&gt;git merge &lt;span class="nt"&gt;--abort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a one-file toy repo that's a nuisance. On a real branch it's worse than a&lt;br&gt;
nuisance, because a branch hasn't been just a branch for years now. Touching the&lt;br&gt;
working tree restarts dev servers, invalidates caches, triggers file watchers,&lt;br&gt;
and re-runs whatever your editor does on change. You asked a question and paid&lt;br&gt;
for it with your environment — &lt;a href="https://gitdesktop.app/blog/the-whole-loop-one-window/" rel="noopener noreferrer"&gt;the exact tax I built this client to&lt;br&gt;
stop&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And if you're merging into a branch you don't have checked out, this approach&lt;br&gt;
doesn't even apply. You'd have to check it out first.&lt;/p&gt;
&lt;h2&gt;
  
  
  Git will do the merge in memory instead
&lt;/h2&gt;

&lt;p&gt;Since version 2.38, &lt;code&gt;git merge-tree&lt;/code&gt; can perform a complete, real merge&lt;br&gt;
(rename detection, the works) and write the result to the object database&lt;br&gt;
without going near your working tree, your index, or &lt;code&gt;HEAD&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="nv"&gt;$ &lt;/span&gt;git merge-tree &lt;span class="nt"&gt;--write-tree&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; main feature
c9cc90770ebb9dee14e212f168dd40059cfb605a
shared.txt

Auto-merging shared.txt
CONFLICT &lt;span class="o"&gt;(&lt;/span&gt;content&lt;span class="o"&gt;)&lt;/span&gt;: Merge conflict &lt;span class="k"&gt;in &lt;/span&gt;shared.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit code is the verdict:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exit&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clean. The merge will succeed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Conflict — &lt;strong&gt;or&lt;/strong&gt; Git declined the merge outright.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;td&gt;Git couldn't start at all (unrelated histories exits &lt;code&gt;128&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When the merge runs at all, line one is the OID of the merged tree. On a&lt;br&gt;
conflict, the lines after it are the conflicted paths, then a blank line, then&lt;br&gt;
the human-readable messages. On a clean merge you get line one and nothing else.&lt;br&gt;
Here it runs against a &lt;code&gt;sidebranch&lt;/code&gt; that diverged before &lt;code&gt;main&lt;/code&gt;'s edit and only&lt;br&gt;
adds a file &lt;code&gt;main&lt;/code&gt; has never seen:&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;$ &lt;/span&gt;git merge-tree &lt;span class="nt"&gt;--write-tree&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; main sidebranch
bab3bcfcd19b35a6ecb2881d1602cf080e1cb547
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which leaves that qualifier on exit &lt;code&gt;1&lt;/code&gt;. It does &lt;strong&gt;not&lt;/strong&gt; mean "conflict" by&lt;br&gt;
itself. Ask to merge a branch that doesn't exist and you get &lt;code&gt;1&lt;/code&gt; as well, with a&lt;br&gt;
completely empty stdout and the message going to stderr instead. So test stdout,&lt;br&gt;
not the code alone: empty output means Git refused, which is "unknown", not "a&lt;br&gt;
conflict in zero files".&lt;/p&gt;

&lt;p&gt;Exit &lt;code&gt;0&lt;/code&gt; is broad in its own way, which matters if you're building on this: it&lt;br&gt;
covers a real three-way merge, a plain fast-forward, and an already-up-to-date&lt;br&gt;
branch alike. &lt;code&gt;merge-tree&lt;/code&gt; doesn't distinguish them, so if those mean different&lt;br&gt;
things in your interface, you need a &lt;code&gt;merge-base&lt;/code&gt; check alongside it.&lt;/p&gt;

&lt;p&gt;And your working tree never moved:&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;$ &lt;/span&gt;git status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--abbrev-ref&lt;/span&gt; HEAD
main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's so thoroughly detached from your checkout that it runs fine in a bare repo,&lt;br&gt;
which has no working tree to touch in the first place. That's the property that&lt;br&gt;
makes it useful for a tool: you can preview merging &lt;em&gt;any&lt;/em&gt; branch into &lt;em&gt;any&lt;/em&gt;&lt;br&gt;
other, without either one being checked out.&lt;/p&gt;
&lt;h2&gt;
  
  
  The merged tree is a real object
&lt;/h2&gt;

&lt;p&gt;That OID isn't just an identifier for the answer — it's a tree in your object&lt;br&gt;
database, and every read-only command that takes a tree will take it.&lt;/p&gt;

&lt;p&gt;Want to see what the conflict actually looks like before deciding? Read the file&lt;br&gt;
straight out of the merge result — conflict markers and all:&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;$ TREE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git merge-tree &lt;span class="nt"&gt;--write-tree&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; main feature | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git cat-file &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TREE&lt;/span&gt;&lt;span class="s2"&gt;:shared.txt"&lt;/span&gt;
line1
&lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&amp;lt; main
MAIN
&lt;span class="o"&gt;=======&lt;/span&gt;
FEATURE
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; feature
line3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Want the diffstat of what merging would do to you?&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;$ &lt;/span&gt;git diff &lt;span class="nt"&gt;--stat&lt;/span&gt; main &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TREE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
 shared.txt | 4 ++++
 1 file changed, 4 insertions&lt;span class="o"&gt;(&lt;/span&gt;+&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that stat carefully, though: on a content conflict like this one the tree&lt;br&gt;
holds the marked-up file, so the count includes the conflict markers themselves.&lt;br&gt;
With the default conflict style, three of those four added lines are the&lt;br&gt;
&lt;code&gt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&lt;/code&gt;, &lt;code&gt;=======&lt;/code&gt; and &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; you saw in the &lt;code&gt;cat-file&lt;/code&gt; output — not work&lt;br&gt;
you have to do. Set &lt;code&gt;merge.conflictStyle&lt;/code&gt; to &lt;code&gt;diff3&lt;/code&gt; or &lt;code&gt;zdiff3&lt;/code&gt; and the same&lt;br&gt;
merge reports six, because each conflict also carries a &lt;code&gt;|||||||&lt;/code&gt; section&lt;br&gt;
and the base text.&lt;/p&gt;

&lt;p&gt;Structural conflicts, where the sides disagree about whether a file exists or&lt;br&gt;
where it lives (more on those below), vary more, so don't apply that&lt;br&gt;
subtraction blindly. A &lt;code&gt;modify/delete&lt;/code&gt; leaves one side's file whole with no&lt;br&gt;
markers at all, and its stat is pure real change. But a &lt;code&gt;rename/rename&lt;/code&gt; where&lt;br&gt;
both sides also edited the file writes markers into &lt;em&gt;both&lt;/em&gt; paths, so the&lt;br&gt;
count inflates there too, and a &lt;code&gt;file/directory&lt;/code&gt; clash reports a rename to a&lt;br&gt;
mangled path rather than a file you recognize. Treat the stat as a rough&lt;br&gt;
gauge, not an audit.&lt;/p&gt;

&lt;p&gt;Even with those caveats you know the rough size of the job, which files are&lt;br&gt;
involved, and, for the content conflicts, the exact shape of each one. All&lt;br&gt;
without running a merge.&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap: &lt;code&gt;-X ours&lt;/code&gt; does less than you think
&lt;/h2&gt;

&lt;p&gt;Now for the part that will lie to you if you build on it.&lt;/p&gt;

&lt;p&gt;If a preview says "conflict", the obvious next thought is: fine, I'll merge with&lt;br&gt;
a strategy option and let Git pick a side. So you re-run the preview mentally,&lt;br&gt;
decide &lt;code&gt;-X ours&lt;/code&gt; will mop it up, and report "3 conflicts, all auto-resolvable".&lt;/p&gt;

&lt;p&gt;Sometimes that's true. &lt;code&gt;-X ours&lt;/code&gt; really does resolve the content conflict above:&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;$ &lt;/span&gt;git merge-tree &lt;span class="nt"&gt;--write-tree&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; ours main feature
51c500ddf270e2f1f6bdf80e5af7fb598c95910b
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean. Different tree, no conflicts, exit 0.&lt;/p&gt;

&lt;p&gt;Now the same experiment where &lt;code&gt;feature&lt;/code&gt; edited a file that &lt;code&gt;main&lt;/code&gt; deleted:&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;$ &lt;/span&gt;git merge-tree &lt;span class="nt"&gt;--write-tree&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; main feature
61eb2660b922022a67457fa4d76a1687b447e0ee
doomed.txt

CONFLICT &lt;span class="o"&gt;(&lt;/span&gt;modify/delete&lt;span class="o"&gt;)&lt;/span&gt;: doomed.txt deleted &lt;span class="k"&gt;in &lt;/span&gt;main and modified &lt;span class="k"&gt;in &lt;/span&gt;feature.

&lt;span class="nv"&gt;$ &lt;/span&gt;git merge-tree &lt;span class="nt"&gt;--write-tree&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; ours main feature
61eb2660b922022a67457fa4d76a1687b447e0ee
doomed.txt

CONFLICT &lt;span class="o"&gt;(&lt;/span&gt;modify/delete&lt;span class="o"&gt;)&lt;/span&gt;: doomed.txt deleted &lt;span class="k"&gt;in &lt;/span&gt;main and modified &lt;span class="k"&gt;in &lt;/span&gt;feature.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the tree OIDs. They're &lt;strong&gt;identical&lt;/strong&gt;. &lt;code&gt;-X ours&lt;/code&gt; didn't resolve it, didn't&lt;br&gt;
change the outcome, didn't change a single byte of the result. &lt;code&gt;-X theirs&lt;/code&gt;&lt;br&gt;
produces that same OID too.&lt;/p&gt;

&lt;p&gt;The reason is that &lt;code&gt;-X ours&lt;/code&gt; and &lt;code&gt;-X theirs&lt;/code&gt; only arbitrate &lt;strong&gt;content&lt;/strong&gt;&lt;br&gt;
conflicts — cases where one path ends up with two candidate texts and Git&lt;br&gt;
needs only to be told which one wins. They have nothing to say about&lt;br&gt;
&lt;strong&gt;structural&lt;/strong&gt; ones, where the disagreement is about whether the file should&lt;br&gt;
exist, or where it lives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;modify/delete&lt;/code&gt; — one side edited it, the other removed it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rename/delete&lt;/code&gt; — one side moved it, the other deleted it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rename/rename&lt;/code&gt; — one file moved to two different names&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;file/directory&lt;/code&gt; — one side made it a file, the other a directory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Git cannot pick a side there, because there is no "side" to pick; the question&lt;br&gt;
isn't which text wins, it's what the tree should contain. Those stop the merge&lt;br&gt;
no matter which &lt;code&gt;-X&lt;/code&gt; you pass.&lt;/p&gt;

&lt;p&gt;Don't sort by whether it looks structural, though; sort by whether there are two&lt;br&gt;
texts to choose between. &lt;code&gt;add/add&lt;/code&gt; looks structural, but both sides did produce&lt;br&gt;
a file at one path, so there &lt;em&gt;is&lt;/em&gt; a text to pick, and &lt;code&gt;-X ours&lt;/code&gt; resolves it to&lt;br&gt;
exit 0. When in doubt, run the specific case; the category name is a poor guide.&lt;/p&gt;

&lt;p&gt;So if you're predicting the result of a merge that will use a strategy option,&lt;br&gt;
&lt;strong&gt;re-run &lt;code&gt;merge-tree&lt;/code&gt; with that option&lt;/strong&gt; and report what it actually says.&lt;br&gt;
Taking a no-strategy conflict list and relabeling it "will auto-resolve" is&lt;br&gt;
wrong for exactly the conflicts a person most needs warning about.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;"Touches nothing" is a slight overstatement — in two ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It writes objects.&lt;/strong&gt; &lt;code&gt;--write-tree&lt;/code&gt; means what it says: the merged tree and any&lt;br&gt;
merged blobs get written to the object database. How many depends on the merge.&lt;br&gt;
Back in the throwaway &lt;code&gt;shared.txt&lt;/code&gt; repo from earlier, clear the loose-object&lt;br&gt;
count to zero so what follows is a clean delta. Do that &lt;em&gt;only&lt;/em&gt; in a throwaway&lt;br&gt;
repo: &lt;code&gt;git gc --prune=now&lt;/code&gt; discards unreachable objects for good, including&lt;br&gt;
&lt;a href="https://dev.to/blog/recover-a-dropped-git-stash/"&gt;stashes you could otherwise still recover&lt;/a&gt;.&lt;br&gt;
With a clean baseline, the content conflict adds two — a tree, and the blob&lt;br&gt;
holding the marked-up file:&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;$ &lt;/span&gt;git gc &lt;span class="nt"&gt;--prune&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;now      &lt;span class="c"&gt;# throwaway repos only — destroys unreachable objects&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git count-objects &lt;span class="nt"&gt;-v&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^count:'&lt;/span&gt;
count: 0
&lt;span class="nv"&gt;$ &lt;/span&gt;git merge-tree &lt;span class="nt"&gt;--write-tree&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; main feature &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="nv"&gt;$ &lt;/span&gt;git count-objects &lt;span class="nt"&gt;-v&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^count:'&lt;/span&gt;
count: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same thing in the &lt;code&gt;doomed.txt&lt;/code&gt; repo and the count doesn't move at all.&lt;br&gt;
That merge's tree is byte-identical to one Git already had, so there is&lt;br&gt;
nothing new to write.&lt;/p&gt;

&lt;p&gt;The objects that &lt;em&gt;do&lt;/em&gt; get written are unreachable the moment they land — but&lt;br&gt;
"unreachable" is not "gone". &lt;code&gt;git gc&lt;/code&gt; will not delete them: &lt;code&gt;gc.pruneExpire&lt;/code&gt;&lt;br&gt;
defaults to two weeks, so an ordinary gc sweeps them into a cruft pack instead.&lt;br&gt;
The loose count falls back to zero while &lt;code&gt;git cat-file -t &amp;lt;oid&amp;gt;&lt;/code&gt; still&lt;br&gt;
cheerfully answers &lt;code&gt;tree&lt;/code&gt; — so on a current Git the &lt;code&gt;count-objects&lt;/code&gt; snippet&lt;br&gt;
above will tell you they have vanished when they have not. (Before 2.41, when&lt;br&gt;
cruft packs became the default, they simply stayed loose and the count stayed&lt;br&gt;
at two.) Only &lt;code&gt;git gc --prune=now&lt;/code&gt; actually removes them. The cost is real but&lt;br&gt;
small, and it does clear itself — on Git's schedule, not on yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It needs a reasonably current Git.&lt;/strong&gt; Both &lt;code&gt;--write-tree&lt;/code&gt; and &lt;code&gt;--name-only&lt;/code&gt;&lt;br&gt;
arrived in Git 2.38, released on 2 October 2022. On anything older, the only&lt;br&gt;
&lt;code&gt;merge-tree&lt;/code&gt; you get is the one the manual now files under "DEPRECATED&lt;br&gt;
DESCRIPTION" — a trivial merge that, in its own words, cannot "handle content&lt;br&gt;
merges of individual files, rename detection, proper directory/file conflict&lt;br&gt;
handling, etc."&lt;/p&gt;

&lt;p&gt;That mode is still reachable today as &lt;code&gt;--trivial-merge&lt;/code&gt;, and you don't want it:&lt;br&gt;
it exits &lt;strong&gt;0&lt;/strong&gt; even when it reports a conflict. A tool that shells out without&lt;br&gt;
checking the version doesn't get an error on old Git — it gets a confidently&lt;br&gt;
clean answer about a merge that will not be clean. Check the version, and&lt;br&gt;
degrade to showing nothing rather than showing something wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or don't do any of this
&lt;/h2&gt;

&lt;p&gt;You can see the pitch coming (I make one of these clients), so here&lt;br&gt;
it is, plainly.&lt;/p&gt;

&lt;p&gt;None of this is wasted knowledge. Knowing that Git will merge in memory for&lt;br&gt;
free, and that &lt;code&gt;-X ours&lt;/code&gt; can't rescue a &lt;code&gt;modify/delete&lt;/code&gt;, makes you harder to&lt;br&gt;
surprise. But it's also a shell pipeline you have to remember, at the&lt;br&gt;
exact moment you're trying to decide something else.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://gitdesktop.app/features/" rel="noopener noreferrer"&gt;GitDesktop&lt;/a&gt; a &lt;code&gt;merge-base&lt;/code&gt; check runs before the merge button&lt;br&gt;
does anything, and that same &lt;code&gt;merge-tree&lt;/code&gt; call runs when it's needed: already up&lt;br&gt;
to date, fast-forward, clean, or conflicted with the file list already on screen&lt;br&gt;
— and if you change the on-conflict strategy, the preview re-runs with that&lt;br&gt;
strategy instead of guessing on your behalf. It's the same plumbing this post&lt;br&gt;
just walked through, minus the remembering.&lt;/p&gt;

&lt;p&gt;Shell or client, the point is the same: the answer was always available. A&lt;br&gt;
merge is not the only way to find out what a merge would do.&lt;/p&gt;

</description>
      <category>git</category>
    </item>
    <item>
      <title>Recovering a dropped stash: what git fsck can still find</title>
      <dc:creator>Evan</dc:creator>
      <pubDate>Tue, 25 Aug 2026 16:20:06 +0000</pubDate>
      <link>https://dev.to/thebguy/recovering-a-dropped-stash-what-git-fsck-can-still-find-50g9</link>
      <guid>https://dev.to/thebguy/recovering-a-dropped-stash-what-git-fsck-can-still-find-50g9</guid>
      <description>&lt;p&gt;You stashed something, moved on, and now it's gone. Maybe you ran &lt;code&gt;git stash&lt;br&gt;
drop&lt;/code&gt; one too many times. Maybe a rebase went sideways and took the stash list&lt;br&gt;
with it. Maybe you ran &lt;code&gt;git stash pop&lt;/code&gt; into a conflict, cleaned up, and&lt;br&gt;
dropped the entry before noticing the resolution was wrong.&lt;/p&gt;

&lt;p&gt;The good news is that Git almost never deletes anything on the spot. A dropped&lt;br&gt;
stash is unlinked, not erased, and it stays in the object database until garbage&lt;br&gt;
collection decides otherwise. Here's how to get it back.&lt;/p&gt;
&lt;h2&gt;
  
  
  A stash is just commits
&lt;/h2&gt;

&lt;p&gt;This is the part that makes the recovery make sense. &lt;code&gt;git stash push&lt;/code&gt; doesn't&lt;br&gt;
write to a special sidecar file — it creates real commits and points a ref at&lt;br&gt;
them. Two commits, in the normal case:&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;$ &lt;/span&gt;git stash push &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"wip: the thing I care about"&lt;/span&gt;
Saved working directory and index state On main: wip: the thing I care about
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what that actually produced:&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;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%h  parents=%p  %s'&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--reflog&lt;/span&gt;
45199d0  &lt;span class="nv"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;61bfbab 813b9d5  On main: wip: the thing I care about
813b9d5  &lt;span class="nv"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;61bfbab            index on main: 61bfbab init
61bfbab  &lt;span class="nv"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;                   init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;index on main&lt;/code&gt; commit holds what was staged. The &lt;code&gt;On main:&lt;/code&gt; commit holds&lt;br&gt;
the working tree, and it has &lt;strong&gt;two parents&lt;/strong&gt;: the commit you were sitting on,&lt;br&gt;
and that index commit. &lt;code&gt;stash@{0}&lt;/code&gt; is just a ref pointing at the second one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git stash drop&lt;/code&gt; deletes the ref. The commits are untouched. They're simply&lt;br&gt;
unreachable now — nothing points to them, so nothing walks to them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Finding it again
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git fsck&lt;/code&gt; walks the object database and reports anything nothing else points&lt;br&gt;
at:&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;$ &lt;/span&gt;git fsck &lt;span class="nt"&gt;--unreachable&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;commit
unreachable commit 813b9d5fb7fef45cc200e7b0f10e5a1e60714c1d
unreachable commit 45199d06525ecbd5658491c9b02a04f73ebbac64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two hits for one stash — which is exactly where most recovery instructions leave&lt;br&gt;
you guessing. They are not interchangeable. Apply the wrong one and you get only&lt;br&gt;
what happened to be staged at the time, which can look convincingly like your&lt;br&gt;
work while quietly missing most of it.&lt;/p&gt;

&lt;p&gt;The stash commit is the one with two parents. You can read that straight off the&lt;br&gt;
object:&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;$ &lt;/span&gt;git cat-file &lt;span class="nt"&gt;-p&lt;/span&gt; 45199d06 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^parent'&lt;/span&gt;
parent 61bfbab...
parent 813b9d5...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or let the shell pick it for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git fsck &lt;span class="nt"&gt;--unreachable&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;commit | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $3}'&lt;/span&gt; |
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read &lt;/span&gt;sha&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git cat-file &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sha&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'^parent'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; 2 &lt;span class="o"&gt;]&lt;/span&gt; &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;"&lt;/span&gt;&lt;span class="nv"&gt;$sha&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git log &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sha&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints only real stash commits, each with the message you wrote — which is&lt;br&gt;
usually enough to recognize the one you want.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting the work back
&lt;/h2&gt;

&lt;p&gt;Once you have the SHA, &lt;code&gt;git stash apply&lt;/code&gt; takes it directly. No ref required:&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;$ &lt;/span&gt;git stash apply 45199d06525ecbd5658491c9b02a04f73ebbac64
On branch main
Changes not staged &lt;span class="k"&gt;for &lt;/span&gt;commit:
    modified:   a.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your changes are back in the working tree.&lt;/p&gt;

&lt;p&gt;If you'd rather look before you leap, inspect it first — a stash commit diffs&lt;br&gt;
against its first parent like anything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git show 45199d06                &lt;span class="c"&gt;# the working-tree changes&lt;/span&gt;
git show 45199d06^2              &lt;span class="c"&gt;# what was staged at stash time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you want it back in the stash list as a proper entry rather than applied&lt;br&gt;
straight to your tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git stash store &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"recovered"&lt;/span&gt; 45199d06
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The catch
&lt;/h2&gt;

&lt;p&gt;This works because the objects are still there, and that isn't forever. &lt;code&gt;git gc&lt;/code&gt;&lt;br&gt;
prunes unreachable objects once they age past &lt;code&gt;gc.pruneExpire&lt;/code&gt;, which defaults to&lt;br&gt;
two weeks. Garbage collection also runs automatically — &lt;code&gt;git gc --auto&lt;/code&gt; fires&lt;br&gt;
during ordinary commands once enough loose objects pile up.&lt;/p&gt;

&lt;p&gt;So: a stash you dropped this morning is almost certainly recoverable. One from&lt;br&gt;
last quarter probably isn't. If you've just realized something important is&lt;br&gt;
missing, do the &lt;code&gt;fsck&lt;/code&gt; now and don't run &lt;code&gt;git gc&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;One thing that will &lt;em&gt;not&lt;/em&gt; help: &lt;code&gt;git reflog&lt;/code&gt;. The stash has its own reflog at&lt;br&gt;
&lt;code&gt;refs/stash&lt;/code&gt;, and dropping the stash takes that with it. The plain &lt;code&gt;git reflog&lt;/code&gt;&lt;br&gt;
only covers &lt;code&gt;HEAD&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or don't do any of this
&lt;/h2&gt;

&lt;p&gt;This is where I mention that I make a Git client. Everything above is a&lt;br&gt;
recipe I'd rather nobody had to run under pressure. Knowing that a stash is two&lt;br&gt;
commits and that the real one has two parents is useful; I'd teach it to&lt;br&gt;
anyone. But at the moment you actually need it, you're stressed, you're&lt;br&gt;
guessing at SHAs, and the failure mode is silently restoring the wrong half&lt;br&gt;
of your work.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://gitdesktop.app/#recover" rel="noopener noreferrer"&gt;GitDesktop&lt;/a&gt; that same &lt;code&gt;fsck&lt;/code&gt; walk runs behind a list:&lt;br&gt;
recoverable stashes, each with its message, its date, and a diff you can read&lt;br&gt;
before you commit to anything. Same objects, same operation, minus the shell&lt;br&gt;
pipeline and minus the chance of applying the index twin by mistake.&lt;/p&gt;

&lt;p&gt;Either way, remember the part that surprises people: it's still there. Git is&lt;br&gt;
much more reluctant to destroy your work than it looks.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>tutorial</category>
      <category>software</category>
      <category>git</category>
    </item>
    <item>
      <title>The Whole Loop, One Window</title>
      <dc:creator>Evan</dc:creator>
      <pubDate>Mon, 17 Aug 2026 23:22:13 +0000</pubDate>
      <link>https://dev.to/thebguy/the-whole-loop-one-window-561g</link>
      <guid>https://dev.to/thebguy/the-whole-loop-one-window-561g</guid>
      <description>&lt;p&gt;I've spent years working with Git.&lt;/p&gt;

&lt;p&gt;Not just using it, but living in it. Feature branches, code reviews, releases, hotfixes, open source, side projects, client work—the rhythm becomes second nature.&lt;/p&gt;

&lt;p&gt;And yet, over time, I noticed something strange.&lt;/p&gt;

&lt;p&gt;The moment I needed to do anything beyond committing code, I was expected to leave.&lt;/p&gt;

&lt;p&gt;Leave the Git client to create a pull request.&lt;/p&gt;

&lt;p&gt;Leave it to review changes.&lt;/p&gt;

&lt;p&gt;Leave it to check project traffic.&lt;/p&gt;

&lt;p&gt;Leave it to manage branches.&lt;/p&gt;

&lt;p&gt;Leave it to use AI.&lt;/p&gt;

&lt;p&gt;Leave it to enforce the kinds of guardrails that prevent simple mistakes.&lt;/p&gt;

&lt;p&gt;It never felt intentional. It was just... accepted.&lt;/p&gt;

&lt;p&gt;But the more I thought about it, the more one question kept coming back:&lt;/p&gt;

&lt;p&gt;Why am I leaving?&lt;/p&gt;

&lt;p&gt;That question became GitDesktop.&lt;/p&gt;

&lt;p&gt;When I first started planning the project, I wasn't trying to build a better GitHub Desktop.&lt;/p&gt;

&lt;p&gt;I wasn't trying to cram every Git feature imaginable into one application.&lt;/p&gt;

&lt;p&gt;The original idea was much simpler.&lt;/p&gt;

&lt;p&gt;I wanted to build a calm Git client I could leave open all day.&lt;/p&gt;

&lt;p&gt;Somewhere along the way, that idea became five words that ended up guiding every feature I built:&lt;/p&gt;

&lt;p&gt;The whole loop, one window.&lt;/p&gt;

&lt;p&gt;It wasn't a tagline.&lt;/p&gt;

&lt;p&gt;It was a design constraint.&lt;/p&gt;

&lt;p&gt;Every time I wanted to add something, I asked the same question:&lt;/p&gt;

&lt;p&gt;Does this help me stay in the flow, or does it send me somewhere else?&lt;/p&gt;

&lt;p&gt;Pull Requests shouldn't begin after you push&lt;br&gt;
One of the biggest workflow interruptions for me has always been creating pull requests.&lt;/p&gt;

&lt;p&gt;The workflow looked something like this:&lt;/p&gt;

&lt;p&gt;Stage changes.&lt;/p&gt;

&lt;p&gt;Commit.&lt;/p&gt;

&lt;p&gt;Open GitHub.&lt;/p&gt;

&lt;p&gt;Create a pull request.&lt;/p&gt;

&lt;p&gt;Write the title.&lt;/p&gt;

&lt;p&gt;Write the description.&lt;/p&gt;

&lt;p&gt;Maybe ask AI to help.&lt;/p&gt;

&lt;p&gt;Everything about that process felt disconnected.&lt;/p&gt;

&lt;p&gt;The repository was already on my machine.&lt;/p&gt;

&lt;p&gt;The diffs were already on my machine.&lt;/p&gt;

&lt;p&gt;The commit history was already on my machine.&lt;/p&gt;

&lt;p&gt;So why did the workflow suddenly move into a browser?&lt;/p&gt;

&lt;p&gt;When GitHub introduced Copilot-powered pull request generation, I actually thought it was a great feature.&lt;/p&gt;

&lt;p&gt;But I already preferred Claude.&lt;/p&gt;

&lt;p&gt;I didn't want to switch assistants depending on where I happened to be working.&lt;/p&gt;

&lt;p&gt;I wanted the workflow—not the provider.&lt;/p&gt;

&lt;p&gt;That eventually led to one of my favorite features in GitDesktop: Local Pull Requests.&lt;/p&gt;

&lt;p&gt;Not because they're technically interesting.&lt;/p&gt;

&lt;p&gt;Because they challenge an assumption I don't think we've questioned enough.&lt;/p&gt;

&lt;p&gt;We tend to think a pull request begins the moment we push.&lt;/p&gt;

&lt;p&gt;I don't.&lt;/p&gt;

&lt;p&gt;To me, pushing is the moment a pull request graduates from private work to shared work.&lt;/p&gt;

&lt;p&gt;Before that, I'm still thinking.&lt;/p&gt;

&lt;p&gt;I'm still iterating.&lt;/p&gt;

&lt;p&gt;I'm still rewriting descriptions.&lt;/p&gt;

&lt;p&gt;I'm still asking AI to review something.&lt;/p&gt;

&lt;p&gt;I'm still cleaning up commits.&lt;/p&gt;

&lt;p&gt;I'm still deciding whether it's ready for someone else's time.&lt;/p&gt;

&lt;p&gt;Why should any of that require a remote repository?&lt;/p&gt;

&lt;p&gt;GitDesktop lets me prepare the entire review locally before deciding to publish it.&lt;/p&gt;

&lt;p&gt;Not because GitHub can't review code.&lt;/p&gt;

&lt;p&gt;Because I believe the first reviewer should always be yourself.&lt;/p&gt;

&lt;p&gt;Branches are no longer just branches&lt;br&gt;
Years ago, switching branches was almost free.&lt;/p&gt;

&lt;p&gt;Today, a branch is an environment.&lt;/p&gt;

&lt;p&gt;Switching branches can restart development servers.&lt;/p&gt;

&lt;p&gt;Hot module replacement kicks in.&lt;/p&gt;

&lt;p&gt;Dependencies change.&lt;/p&gt;

&lt;p&gt;Generated files change.&lt;/p&gt;

&lt;p&gt;Environment variables change.&lt;/p&gt;

&lt;p&gt;Migrations change.&lt;/p&gt;

&lt;p&gt;I've had moments where I switched back to an old feature branch just to update it and suddenly found hundreds of files appearing as untracked because .gitignore had changed on another branch.&lt;/p&gt;

&lt;p&gt;Not because I intended to work there.&lt;/p&gt;

&lt;p&gt;Simply because Git required me to visit that branch before updating it.&lt;/p&gt;

&lt;p&gt;Another time, I interrupted active development just to pull changes into a long-running epic.&lt;/p&gt;

&lt;p&gt;Everything restarted.&lt;/p&gt;

&lt;p&gt;The application reloaded.&lt;/p&gt;

&lt;p&gt;The context I was in disappeared.&lt;/p&gt;

&lt;p&gt;None of that had anything to do with the work I was actually trying to accomplish.&lt;/p&gt;

&lt;p&gt;I just wanted to bring another branch up to date.&lt;/p&gt;

&lt;p&gt;So GitDesktop lets me do exactly that.&lt;/p&gt;

&lt;p&gt;Update another branch without leaving the one I'm currently working in.&lt;/p&gt;

&lt;p&gt;Not because it's impossible otherwise.&lt;/p&gt;

&lt;p&gt;Because switching contexts should be a choice—not a requirement.&lt;/p&gt;

&lt;p&gt;Guardrails shouldn't be a pricing tier&lt;br&gt;
Some of my favorite GitDesktop features exist because of incredibly ordinary mistakes.&lt;/p&gt;

&lt;p&gt;I've merge committed directly into development branches because muscle memory took over.&lt;/p&gt;

&lt;p&gt;I've deleted the wrong branch after promoting a release.&lt;/p&gt;

&lt;p&gt;None of these were knowledge problems.&lt;/p&gt;

&lt;p&gt;They were human problems.&lt;/p&gt;

&lt;p&gt;GitHub has branch protections.&lt;/p&gt;

&lt;p&gt;They're great.&lt;/p&gt;

&lt;p&gt;But many of the protections I wanted either happen remotely or are tied to higher-tier workflows.&lt;/p&gt;

&lt;p&gt;That always felt backwards.&lt;/p&gt;

&lt;p&gt;The best safety features shouldn't only exist after you've already pushed your code somewhere.&lt;/p&gt;

&lt;p&gt;They shouldn't only exist for large organizations.&lt;/p&gt;

&lt;p&gt;Sometimes you just want your tools to stop you from making an obvious mistake.&lt;/p&gt;

&lt;p&gt;Local branch protection came from that belief.&lt;/p&gt;

&lt;p&gt;Good guardrails should be available wherever you're working.&lt;/p&gt;

&lt;p&gt;Especially when you're working alone.&lt;/p&gt;

&lt;p&gt;Monitoring shouldn't require hunting&lt;br&gt;
Another habit I've developed over the years is checking on my projects.&lt;/p&gt;

&lt;p&gt;Not because I enjoy dashboards.&lt;/p&gt;

&lt;p&gt;Because I genuinely like seeing whether people are using the things I've built.&lt;/p&gt;

&lt;p&gt;How many visitors showed up today?&lt;/p&gt;

&lt;p&gt;Where did they come from?&lt;/p&gt;

&lt;p&gt;Which repositories are getting attention?&lt;/p&gt;

&lt;p&gt;That routine usually meant opening GitHub...&lt;/p&gt;

&lt;p&gt;Then another repository.&lt;/p&gt;

&lt;p&gt;Then another.&lt;/p&gt;

&lt;p&gt;Then another.&lt;/p&gt;

&lt;p&gt;Eventually I had several browser tabs open just to answer one simple question:&lt;/p&gt;

&lt;p&gt;How are my projects doing?&lt;/p&gt;

&lt;p&gt;It felt disconnected.&lt;/p&gt;

&lt;p&gt;The repositories I cared about were already sitting inside the Git client I had open all day.&lt;/p&gt;

&lt;p&gt;Why wasn't that information there too?&lt;/p&gt;

&lt;p&gt;Repository Insights wasn't born out of wanting prettier graphs.&lt;/p&gt;

&lt;p&gt;It was born out of wanting the projects I care about to stay close.&lt;/p&gt;

&lt;p&gt;Bring your own workflow&lt;br&gt;
AI accelerated this realization.&lt;/p&gt;

&lt;p&gt;Every company wants you inside their ecosystem.&lt;/p&gt;

&lt;p&gt;Use their assistant.&lt;/p&gt;

&lt;p&gt;Use their models.&lt;/p&gt;

&lt;p&gt;Use their workflow.&lt;/p&gt;

&lt;p&gt;But developers don't work that way.&lt;/p&gt;

&lt;p&gt;Some people prefer Claude.&lt;/p&gt;

&lt;p&gt;Some use OpenAI.&lt;/p&gt;

&lt;p&gt;Others run local models.&lt;/p&gt;

&lt;p&gt;Tomorrow there will be something new.&lt;/p&gt;

&lt;p&gt;The tool shouldn't force that decision.&lt;/p&gt;

&lt;p&gt;It should adapt to it.&lt;/p&gt;

&lt;p&gt;The same applies beyond AI.&lt;/p&gt;

&lt;p&gt;Whether it's GitHub, GitLab, Bitbucket, or something else, I don't think your development workflow should be dictated by whichever platform happens to host your repository.&lt;/p&gt;

&lt;p&gt;Your tools should work with you.&lt;/p&gt;

&lt;p&gt;Not the other way around.&lt;/p&gt;

&lt;p&gt;Questioning assumptions&lt;br&gt;
As I look back over GitDesktop, I notice something.&lt;/p&gt;

&lt;p&gt;I wasn't really building features.&lt;/p&gt;

&lt;p&gt;I was questioning assumptions.&lt;/p&gt;

&lt;p&gt;Why does a pull request require a remote?&lt;/p&gt;

&lt;p&gt;Why do reviews begin after pushing?&lt;/p&gt;

&lt;p&gt;Why do I have to leave my Git client to understand my project?&lt;/p&gt;

&lt;p&gt;Why should switching AI models change my workflow?&lt;/p&gt;

&lt;p&gt;Why should updating another branch interrupt the one I'm actively working in?&lt;/p&gt;

&lt;p&gt;Why are some of the best guardrails only available after code leaves my machine?&lt;/p&gt;

&lt;p&gt;Every feature traces back to one of those questions.&lt;/p&gt;

&lt;p&gt;The whole loop, one window&lt;br&gt;
If GitHub Desktop adopted every feature I've built tomorrow, I'd be thrilled for developers.&lt;/p&gt;

&lt;p&gt;But I don't think we'd be building the same product.&lt;/p&gt;

&lt;p&gt;Because GitDesktop isn't defined by Local Pull Requests, branch protection, AI integration, or repository insights.&lt;/p&gt;

&lt;p&gt;It's defined by a philosophy.&lt;/p&gt;

&lt;p&gt;I believe developer tools should adapt to developers—not ask developers to adapt to them.&lt;/p&gt;

&lt;p&gt;I believe the best workflows happen as close to the code as possible.&lt;/p&gt;

&lt;p&gt;And I believe every unnecessary context switch is an opportunity to ask a simple question:&lt;/p&gt;

&lt;p&gt;Do I actually need to leave?&lt;/p&gt;

&lt;p&gt;That question has shaped every feature in GitDesktop so far.&lt;/p&gt;

&lt;p&gt;And I suspect it'll shape every feature I build next.&lt;/p&gt;

&lt;p&gt;Because for me, the goal has never been to build another Git client.&lt;/p&gt;

&lt;p&gt;It's been to build the one I never have to leave.&lt;/p&gt;

&lt;p&gt;What's one part of your workflow that always makes you think, "Why do I have to leave?"&lt;/p&gt;

&lt;p&gt;This essay also lives on the GitDesktop blog — &lt;a href="https://gitdesktop.app/blog/the-whole-loop-one-window/" rel="noopener noreferrer"&gt;https://gitdesktop.app/blog/the-whole-loop-one-window/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>git</category>
      <category>ai</category>
      <category>software</category>
    </item>
  </channel>
</rss>
