<?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>git pull --rebase deleted your commit: the rescue and the reason</title>
      <dc:creator>Evan</dc:creator>
      <pubDate>Tue, 15 Sep 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/thebguy/git-pull-rebase-deleted-your-commit-the-rescue-and-the-reason-pnj</link>
      <guid>https://dev.to/thebguy/git-pull-rebase-deleted-your-commit-the-rescue-and-the-reason-pnj</guid>
      <description>&lt;p&gt;This morning &lt;code&gt;git pull --rebase&lt;/code&gt; printed a success line and deleted the&lt;br&gt;
commit you pushed yesterday. No conflict, no error, no prompt. The&lt;br&gt;
working tree is clean, the branch says up to date, and the commit is on&lt;br&gt;
no branch anywhere: not yours, not the remote's.&lt;/p&gt;

&lt;p&gt;Nothing in that story fails, which is what makes it dangerous.&lt;br&gt;
Reproducing it takes two clones and four commits. Watch it happen, get&lt;br&gt;
the commit back, then meet the rule that decided your pushed work was&lt;br&gt;
disposable. Git 2.51, stock configuration, throughout.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two clones, one branch
&lt;/h2&gt;

&lt;p&gt;Your teammate started the branch &lt;code&gt;search&lt;/code&gt; and pushed two commits; you&lt;br&gt;
cloned and joined. Your contribution is a ranking fix:&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;--oneline&lt;/span&gt;
97d747a Index file paths
5d48fd5 Add search &lt;span class="nb"&gt;command&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git add ranking.js
&lt;span class="nv"&gt;$ &lt;/span&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Rank exact matches first"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;search 5a818d0] Rank exact matches first
 1 file changed, 1 insertion&lt;span class="o"&gt;(&lt;/span&gt;+&lt;span class="o"&gt;)&lt;/span&gt;
 create mode 100644 ranking.js
&lt;span class="nv"&gt;$ &lt;/span&gt;git push
To …/origin.git
   97d747a..5a818d0  search -&amp;gt; search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5a818d0 is on the remote now; anyone who fetches gets it. The push&lt;br&gt;
also did one other thing, quietly, in your own clone. It comes back&lt;br&gt;
when we get to the why.&lt;/p&gt;
&lt;h2&gt;
  
  
  The force push you never saw
&lt;/h2&gt;

&lt;p&gt;Your teammate's clone hasn't fetched since before your push, so their&lt;br&gt;
branch still ends at 97d747a, and they've just committed a filter:&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 add filters.js
&lt;span class="nv"&gt;$ &lt;/span&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Match on file type"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;search d5c109a] Match on file &lt;span class="nb"&gt;type
 &lt;/span&gt;1 file changed, 1 insertion&lt;span class="o"&gt;(&lt;/span&gt;+&lt;span class="o"&gt;)&lt;/span&gt;
 create mode 100644 filters.js
&lt;span class="nv"&gt;$ &lt;/span&gt;git push
To …/origin.git
 &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;rejected]        search -&amp;gt; search &lt;span class="o"&gt;(&lt;/span&gt;fetch first&lt;span class="o"&gt;)&lt;/span&gt;
error: failed to push some refs to &lt;span class="s1"&gt;'…/origin.git'&lt;/span&gt;
hint: Updates were rejected because the remote contains work that you &lt;span class="k"&gt;do &lt;/span&gt;not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: &lt;span class="s1"&gt;'git pull'&lt;/span&gt; before pushing again.
hint: See the &lt;span class="s1"&gt;'Note about fast-forwards'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s1"&gt;'git push --help'&lt;/span&gt; &lt;span class="k"&gt;for &lt;/span&gt;details.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The work the remote contains that they do not have is your commit. The&lt;br&gt;
refusal is Git protecting it, and the hint says how to take it in. They&lt;br&gt;
read the refusal as friction:&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 push &lt;span class="nt"&gt;--force&lt;/span&gt;
To …/origin.git
 + 5a818d0...d5c109a search -&amp;gt; search &lt;span class="o"&gt;(&lt;/span&gt;forced update&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://gitdesktop.app/blog/force-push-without-overwriting-work/" rel="noopener noreferrer"&gt;The last post&lt;/a&gt; was about&lt;br&gt;
this moment from the pusher's side, and about the flags that would have&lt;br&gt;
refused it. Today the interest is on your side of the wire, because&lt;br&gt;
nothing so far has touched your clone. The remote lost its copy of&lt;br&gt;
5a818d0; yours is intact, checked out, one &lt;code&gt;git log&lt;/code&gt; away. The deletion&lt;br&gt;
that sticks is going to be yours.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pull
&lt;/h2&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 pull &lt;span class="nt"&gt;--rebase&lt;/span&gt;
From …/origin
 + 5a818d0...d5c109a search     -&amp;gt; origin/search  &lt;span class="o"&gt;(&lt;/span&gt;forced update&lt;span class="o"&gt;)&lt;/span&gt;
Successfully rebased and updated refs/heads/search.
&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt;
d5c109a Match on file &lt;span class="nb"&gt;type
&lt;/span&gt;97d747a Index file paths
5d48fd5 Add search &lt;span class="nb"&gt;command&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git status
On branch search
Your branch is up to &lt;span class="nb"&gt;date &lt;/span&gt;with &lt;span class="s1"&gt;'origin/search'&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;

nothing to commit, working tree clean
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Your commit is gone, and every signal reads as routine. The fetch did&lt;br&gt;
disclose what it saw: one &lt;code&gt;(forced update)&lt;/code&gt; line, the same dialect the&lt;br&gt;
last post decoded. The rebase then proceeded as if that line settled&lt;br&gt;
things. "Up to date" here means agreeing with a remote that was&lt;br&gt;
force-pushed a minute ago.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rescue
&lt;/h2&gt;

&lt;p&gt;Reason later; rescue the commit first. The branch's reflog (the local diary of&lt;br&gt;
the positions &lt;code&gt;search&lt;/code&gt; has held in your clone) still lists 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 reflog search
d5c109a search@&lt;span class="o"&gt;{&lt;/span&gt;0&lt;span class="o"&gt;}&lt;/span&gt;: pull &lt;span class="nt"&gt;--rebase&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;finish&lt;span class="o"&gt;)&lt;/span&gt;: refs/heads/search onto d5c109ab3f4881e4f58b8de14947c0cb23a202ee
5a818d0 search@&lt;span class="o"&gt;{&lt;/span&gt;1&lt;span class="o"&gt;}&lt;/span&gt;: commit: Rank exact matches first
97d747a search@&lt;span class="o"&gt;{&lt;/span&gt;2&lt;span class="o"&gt;}&lt;/span&gt;: clone: from …/origin.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;search@{1}&lt;/code&gt; is the branch as it stood before the pull rewrote it. If&lt;br&gt;
your tree isn't clean, &lt;code&gt;git stash --include-untracked&lt;/code&gt; first —&lt;br&gt;
&lt;code&gt;--hard&lt;/code&gt; throws away uncommitted changes. Then move 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="nv"&gt;$ &lt;/span&gt;git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; search@&lt;span class="o"&gt;{&lt;/span&gt;1&lt;span class="o"&gt;}&lt;/span&gt;
HEAD is now at 5a818d0 Rank exact matches first
&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt;
5a818d0 Rank exact matches first
97d747a Index file paths
5d48fd5 Add search &lt;span class="nb"&gt;command&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git status &lt;span class="nt"&gt;-sb&lt;/span&gt;
&lt;span class="c"&gt;## search...origin/search [ahead 1, behind 1]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd already committed new work on top of the pulled branch&lt;br&gt;
before noticing, reset would take it down with the pull's result; in&lt;br&gt;
that case &lt;code&gt;git cherry-pick 5a818d0&lt;/code&gt; brings the lost commit onto the&lt;br&gt;
current state instead. A branch repaired that way already sits on&lt;br&gt;
top of &lt;code&gt;origin/search&lt;/code&gt;, so there is nothing left to integrate: skip&lt;br&gt;
the reset below, &lt;code&gt;git push&lt;/code&gt;, and &lt;code&gt;git stash pop&lt;/code&gt; if you stashed&lt;br&gt;
at the start.&lt;/p&gt;

&lt;p&gt;Ahead 1, behind 1 is the divergence the pull was supposed to resolve,&lt;br&gt;
now out in the open: you have a commit missing from the remote, the&lt;br&gt;
remote has one you haven't integrated. If you took the reset path, that&lt;br&gt;
gets resolved in &lt;em&gt;Integrating it properly&lt;/em&gt;, below. First, the reason.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the rebase threw it away
&lt;/h2&gt;

&lt;p&gt;A rebase pull is a fetch, then a rebase of your branch onto the updated&lt;br&gt;
&lt;code&gt;origin/search&lt;/code&gt;. The rebase's first job is drawing a boundary: which&lt;br&gt;
commits are yours to replay onto the new tip, and which belonged to the&lt;br&gt;
old upstream, where replaying would only resurrect discarded history.&lt;br&gt;
Git has two ways to draw that boundary, and they disagree about 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 merge-base origin/search search
97d747a8a37a3b4cf5d3032ff2504761c898646e
&lt;span class="nv"&gt;$ &lt;/span&gt;git merge-base &lt;span class="nt"&gt;--fork-point&lt;/span&gt; origin/search search
5a818d0272d94df7a2b447d349402105f8df8c0a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain &lt;code&gt;merge-base&lt;/code&gt; answers from the commit graph: the two histories&lt;br&gt;
part ways after 97d747a, so everything past it on your branch is yours&lt;br&gt;
to replay — one commit, 5a818d0. That is the boundary an explicit&lt;br&gt;
&lt;code&gt;git rebase origin/search&lt;/code&gt; would use.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--fork-point&lt;/code&gt; is what &lt;code&gt;git pull&lt;/code&gt; uses, and it consults something the&lt;br&gt;
graph doesn't have: the reflog of your remote-tracking ref.&lt;br&gt;
&lt;code&gt;origin/search&lt;/code&gt; is your clone's private record of the remote branch,&lt;br&gt;
and it keeps a diary of its own:&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 reflog origin/search
d5c109a refs/remotes/origin/search@&lt;span class="o"&gt;{&lt;/span&gt;0&lt;span class="o"&gt;}&lt;/span&gt;: pull &lt;span class="nt"&gt;--rebase&lt;/span&gt;: forced-update
5a818d0 refs/remotes/origin/search@&lt;span class="o"&gt;{&lt;/span&gt;1&lt;span class="o"&gt;}&lt;/span&gt;: update by push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;update by push&lt;/code&gt;. Your push didn't just send the commit; it recorded,&lt;br&gt;
in your own clone, that the upstream branch has stood at 5a818d0. The&lt;br&gt;
fork-point rule treats every position in that diary as upstream&lt;br&gt;
history — and the upstream has since moved off 5a818d0, so by its&lt;br&gt;
reading, the upstream considered your commit and threw it away. The&lt;br&gt;
boundary lands on the commit itself, the replay set is empty, and the&lt;br&gt;
rebase does exactly what it was asked, for the branch it believed it&lt;br&gt;
was looking at.&lt;/p&gt;

&lt;p&gt;The rule exists for a real case. If a maintainer rewrites a branch&lt;br&gt;
under commits you have &lt;em&gt;not&lt;/em&gt; pushed, fork-point is what stops the&lt;br&gt;
rebase from replaying the abandoned upstream commits underneath your&lt;br&gt;
work: no duplicates, no phantom conflicts from history the rewrite&lt;br&gt;
meant to delete. It guards unpushed work sitting on rewritten history.&lt;br&gt;
The trap is that the diary records positions, never ownership. A commit&lt;br&gt;
you pushed and a commit the upstream abandoned are indistinguishable in&lt;br&gt;
it, and your own push is what put yours on the list. That is the quiet&lt;br&gt;
other thing the push did back in the first block.&lt;/p&gt;
&lt;h2&gt;
  
  
  The flag that doesn't exist
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git rebase&lt;/code&gt; can be told not to do this: &lt;code&gt;--no-fork-point&lt;/code&gt;, or&lt;br&gt;
&lt;code&gt;rebase.forkPoint=false&lt;/code&gt; in config. Neither survives contact with&lt;br&gt;
&lt;code&gt;git pull&lt;/code&gt;. The flag first:&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 pull &lt;span class="nt"&gt;--rebase&lt;/span&gt; &lt;span class="nt"&gt;--no-fork-point&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
error: unknown option &lt;span class="sb"&gt;`&lt;/span&gt;no-fork-point&lt;span class="s1"&gt;'
usage: git pull [&amp;lt;options&amp;gt;] [&amp;lt;repository&amp;gt; [&amp;lt;refspec&amp;gt;...]]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The config setting looks like it should reach the rebase that pull&lt;br&gt;
runs underneath. Watch it not do that: this next run has&lt;br&gt;
&lt;code&gt;rebase.forkPoint=false&lt;/code&gt; set, and &lt;code&gt;GIT_TRACE&lt;/code&gt; prints what pull executes:&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;$ GIT_TRACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 git &lt;span class="nt"&gt;-c&lt;/span&gt; rebase.forkPoint&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false &lt;/span&gt;pull &lt;span class="nt"&gt;--rebase&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s2"&gt;"run_command: git (merge-base|rebase).*"&lt;/span&gt;
run_command: git merge-base &lt;span class="nt"&gt;--fork-point&lt;/span&gt; refs/remotes/origin/search search
run_command: git rebase &lt;span class="nt"&gt;--no-autostash&lt;/span&gt; &lt;span class="nt"&gt;--onto&lt;/span&gt; d5c109ab3f4881e4f58b8de14947c0cb23a202ee 5a818d0272d94df7a2b447d349402105f8df8c0a
&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt;
d5c109a Match on file &lt;span class="nb"&gt;type
&lt;/span&gt;97d747a Index file paths
5d48fd5 Add search &lt;span class="nb"&gt;command&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gone again, config and all. The trace is the explanation. Pull computes&lt;br&gt;
the fork point itself (the first line) and hands the verdict to rebase&lt;br&gt;
as an explicit &lt;code&gt;--onto&lt;/code&gt;: rebase onto d5c109a, replaying whatever&lt;br&gt;
follows 5a818d0. By the time rebase would consult &lt;code&gt;rebase.forkPoint&lt;/code&gt;,&lt;br&gt;
the decision is already spelled out in its arguments, and a setting&lt;br&gt;
can't override an argument. As of Git 2.51 there is no pull-side flag&lt;br&gt;
and no config that reaches this: every rebase pull across a rewritten&lt;br&gt;
upstream gets the fork-point verdict whenever the reflog can supply&lt;br&gt;
one — and your own push made sure it can.&lt;/p&gt;
&lt;h2&gt;
  
  
  Integrating it properly
&lt;/h2&gt;

&lt;p&gt;So the fix has to happen outside the pull. First, back to the rescued&lt;br&gt;
commit — by hash this time: if you skipped the traced demonstration,&lt;br&gt;
your reflog positions differ from mine, and the hash is correct on&lt;br&gt;
either path. Then rebase onto the explicit name — naming the upstream&lt;br&gt;
is what makes rebase use the plain merge-base boundary:&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 reset &lt;span class="nt"&gt;--hard&lt;/span&gt; 5a818d0
HEAD is now at 5a818d0 Rank exact matches first
&lt;span class="nv"&gt;$ &lt;/span&gt;git rebase origin/search
Successfully rebased and updated refs/heads/search.
&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt;
aeae0fd Rank exact matches first
d5c109a Match on file &lt;span class="nb"&gt;type
&lt;/span&gt;97d747a Index file paths
5d48fd5 Add search &lt;span class="nb"&gt;command&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git push
To …/origin.git
   d5c109a..aeae0fd  search -&amp;gt; search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your change is back on the branch: aeae0fd, a new hash because the&lt;br&gt;
parent changed, same message and content, sitting on top of your&lt;br&gt;
teammate's work, published with an ordinary fast-forward push. Both&lt;br&gt;
commits survive. This is what the pull would have done had it drawn the&lt;br&gt;
boundary from the graph instead of the diary. If you stashed at the&lt;br&gt;
start, &lt;code&gt;git stash pop&lt;/code&gt; brings that work back; the incident is over.&lt;br&gt;
(A bare &lt;code&gt;git rebase&lt;/code&gt; with no upstream argument defaults to the same&lt;br&gt;
fork-point rule as pull, so name the upstream when it matters.)&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;(forced update)&lt;/code&gt; line in any fetch or pull output is the only&lt;br&gt;
warning you get. When you see it, stop pulling by reflex. Read what moved&lt;br&gt;
(&lt;code&gt;git log --oneline origin/search&lt;/code&gt;), check whether your own pushed&lt;br&gt;
commits are still part of it, and integrate with an explicit&lt;br&gt;
&lt;code&gt;git rebase origin/&amp;lt;branch&amp;gt;&lt;/code&gt; so the boundary comes from the graph.&lt;/p&gt;

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

&lt;p&gt;The check you just ran by hand is two read-only commands: fork point&lt;br&gt;
against merge base, with the at-risk commits sitting between them. A&lt;br&gt;
Git client can afford to run that check before every rebase pull.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gitdesktop.app/features/" rel="noopener noreferrer"&gt;GitDesktop&lt;/a&gt; runs it as a pre-flight check. When the two&lt;br&gt;
answers agree, the pull proceeds as normal. When they disagree, it&lt;br&gt;
stops before the rebase and asks "Keep or drop these commits?", with&lt;br&gt;
each at-risk commit listed by id, subject, and date. Keeping them&lt;br&gt;
replays them on top of the rewritten upstream, the same move as the&lt;br&gt;
explicit rebase above, and it's the focused default. Dropping them is&lt;br&gt;
allowed too (sometimes the rewrite really did mean to take your commit&lt;br&gt;
with it), and a drop is recorded in Operation history rather than&lt;br&gt;
trusted to memory. The transcript above, with its one success line,&lt;br&gt;
becomes a question with the evidence attached.&lt;/p&gt;

&lt;p&gt;A success message tells you the plan worked. It never tells you the&lt;br&gt;
plan was right.&lt;/p&gt;

</description>
      <category>git</category>
      <category>software</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Work on two branches at once with git worktree</title>
      <dc:creator>Evan</dc:creator>
      <pubDate>Thu, 10 Sep 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/thebguy/work-on-two-branches-at-once-with-git-worktree-34mp</link>
      <guid>https://dev.to/thebguy/work-on-two-branches-at-once-with-git-worktree-34mp</guid>
      <description>&lt;p&gt;You're an hour into a parser rework. Half the call sites are on the new&lt;br&gt;
signature, the tests are red on purpose, and there's an untracked file&lt;br&gt;
of notes you'd rather not explain. Then a bug report lands: production&lt;br&gt;
crashes on a null payload. The fix belongs on &lt;code&gt;main&lt;/code&gt;, and it can't wait&lt;br&gt;
for your rework.&lt;/p&gt;

&lt;p&gt;So you do the stash dance: &lt;code&gt;git stash&lt;/code&gt;, switch, fix, push, switch back,&lt;br&gt;
&lt;code&gt;git stash pop&lt;/code&gt;, and hope the pop applies. Usually it does. The times&lt;br&gt;
it doesn't, you're resolving conflicts inside work that was&lt;br&gt;
half-finished on purpose, the one place where you have no idea what&lt;br&gt;
"correct" looks like.&lt;/p&gt;

&lt;p&gt;Underneath the dance sits an assumption: a repository has one working&lt;br&gt;
tree, so branches take turns using it. That assumption became optional&lt;br&gt;
in 2015, when Git 2.5 shipped &lt;code&gt;git worktree&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  A second folder, same repository
&lt;/h2&gt;

&lt;p&gt;Leave the mess exactly where it is:&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 branch &lt;span class="nt"&gt;--show-current&lt;/span&gt;
feature
&lt;span class="nv"&gt;$ &lt;/span&gt;git status &lt;span class="nt"&gt;--short&lt;/span&gt;
 M src/parser.js
?? notes.md
&lt;span class="nv"&gt;$ &lt;/span&gt;git worktree add &lt;span class="nt"&gt;-b&lt;/span&gt; fix-crash ../hotfix origin/main
Preparing worktree &lt;span class="o"&gt;(&lt;/span&gt;new branch &lt;span class="s1"&gt;'fix-crash'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
branch &lt;span class="s1"&gt;'fix-crash'&lt;/span&gt; &lt;span class="nb"&gt;set &lt;/span&gt;up to track &lt;span class="s1"&gt;'origin/main'&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
HEAD is now at 812aeba Expand usage docs &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="c"&gt;#12)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That created a &lt;em&gt;linked worktree&lt;/em&gt;: a second working tree in its own&lt;br&gt;
folder, attached to the repository you ran the command from.&lt;br&gt;
&lt;code&gt;-b fix-crash&lt;/code&gt; cuts the branch, &lt;code&gt;../hotfix&lt;/code&gt; is where the checkout&lt;br&gt;
goes, and &lt;code&gt;origin/main&lt;/code&gt; is the base (fetch first, so it sits at the&lt;br&gt;
remote's tip when you branch off it). The tracking line is ordinary&lt;br&gt;
branching-from-a-remote behavior; nothing about it is worktree-specific.&lt;/p&gt;

&lt;p&gt;"Attached" is the key word. This is not a second clone. There is one&lt;br&gt;
object database, one set of branches, one config, one list of&lt;br&gt;
remotes; the new folder holds a checkout and nothing else:&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 worktree list
…/app     542eaf6 &lt;span class="o"&gt;[&lt;/span&gt;feature]
…/hotfix  812aeba &lt;span class="o"&gt;[&lt;/span&gt;fix-crash]
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; ../hotfix/.git
gitdir: …/app/.git/worktrees/hotfix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.git&lt;/code&gt; in a linked worktree is a file, not a directory: a pointer&lt;br&gt;
back to the real repository, which keeps the linked checkout's&lt;br&gt;
bookkeeping under &lt;code&gt;.git/worktrees/&lt;/code&gt;. What each worktree does own is a&lt;br&gt;
HEAD, an index, and its working files. The split runs along one line:&lt;br&gt;
the things that make up "where I'm standing" are per-worktree, and the&lt;br&gt;
things that make up "the repository" exist once.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix, without the dance
&lt;/h2&gt;

&lt;p&gt;A fresh worktree checks out tracked files only, so ignored files&lt;br&gt;
like &lt;code&gt;node_modules&lt;/code&gt; and build output start absent; this one-line&lt;br&gt;
fix doesn't need them. Open &lt;code&gt;../hotfix&lt;/code&gt; in a second editor window,&lt;br&gt;
make the fix, and commit it over there:&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 &lt;span class="nt"&gt;-C&lt;/span&gt; ../hotfix commit &lt;span class="nt"&gt;-am&lt;/span&gt; &lt;span class="s2"&gt;"fix: guard null payload"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;fix-crash 0a21336] fix: guard null payload
 1 file changed, 1 insertion&lt;span class="o"&gt;(&lt;/span&gt;+&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; fix-crash
0a21336 fix: guard null payload
&lt;span class="nv"&gt;$ &lt;/span&gt;git push origin fix-crash
To …/origin.git
 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;new branch]      fix-crash -&amp;gt; fix-crash
&lt;span class="nv"&gt;$ &lt;/span&gt;git status &lt;span class="nt"&gt;--short&lt;/span&gt;
 M src/parser.js
?? notes.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look where the last three commands ran: in your original checkout, the&lt;br&gt;
seat you never left. The commit was made in &lt;code&gt;../hotfix&lt;/code&gt;, yet from the&lt;br&gt;
&lt;code&gt;feature&lt;/code&gt; seat it was already there: not fetched, not copied, just&lt;br&gt;
there, because there is only one repository. The push works from&lt;br&gt;
either folder for the same reason. And your own working tree sat out&lt;br&gt;
the whole thing: the same modified file, the same untracked notes,&lt;br&gt;
the dev server still warm. Open a PR for &lt;code&gt;fix-crash&lt;/code&gt; and get back&lt;br&gt;
to the rework.&lt;/p&gt;
&lt;h2&gt;
  
  
  One branch, one checkout
&lt;/h2&gt;

&lt;p&gt;If both folders share the branches, can they share a branch?&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 switch fix-crash
fatal: &lt;span class="s1"&gt;'fix-crash'&lt;/span&gt; is already used by worktree at &lt;span class="s1"&gt;'…/hotfix'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No, and the refusal is the design. A branch is one shared pointer, but&lt;br&gt;
each worktree built its index and working files against wherever its&lt;br&gt;
own HEAD stood. If two worktrees stood on &lt;code&gt;fix-crash&lt;/code&gt; and one of them&lt;br&gt;
committed, the other would be standing on history that had moved under&lt;br&gt;
it. Git refuses up front instead.&lt;/p&gt;

&lt;p&gt;You've met this guard before if you've read &lt;a href="https://dev.to/blog/update-a-branch-without-checking-it-out/"&gt;the branch-updating&lt;br&gt;
post&lt;/a&gt;: it's the same&lt;br&gt;
rule that stops &lt;code&gt;git fetch origin main:main&lt;/code&gt; while &lt;code&gt;main&lt;/code&gt; is checked&lt;br&gt;
out anywhere, and the same reason &lt;code&gt;git branch -D&lt;/code&gt; declines to delete&lt;br&gt;
a branch a worktree is using; in every case the error names the&lt;br&gt;
folder to go look at. When you want the files without any branch&lt;br&gt;
question at all, add the worktree with &lt;code&gt;--detach&lt;/code&gt; — a checkout&lt;br&gt;
pinned to a commit, no branch involved.&lt;/p&gt;
&lt;h2&gt;
  
  
  The stash is shared too
&lt;/h2&gt;

&lt;p&gt;The stash list is a ref, and it belongs to the repository, not to&lt;br&gt;
any one checkout:&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;"parser rework, half done"&lt;/span&gt;
Saved working directory and index state On feature: parser rework, half &lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; ../hotfix stash list
stash@&lt;span class="o"&gt;{&lt;/span&gt;0&lt;span class="o"&gt;}&lt;/span&gt;: On feature: parser rework, half &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One list, visible from every seat. Pop it back from the seat that&lt;br&gt;
pushed it. Nothing enforces that: a stash applies to whichever&lt;br&gt;
working tree runs the pop. If you use worktrees and stashes together,&lt;br&gt;
the "On feature" prefix and a real message are what tell you, later,&lt;br&gt;
where a stash came from and where it belongs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Folders are disposable, the bookkeeping isn't
&lt;/h2&gt;

&lt;p&gt;The fix is pushed and the PR is open, so the hotfix worktree has&lt;br&gt;
done its job:&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 worktree remove ../hotfix
&lt;span class="nv"&gt;$ &lt;/span&gt;git branch &lt;span class="nt"&gt;--list&lt;/span&gt; fix-crash
  fix-crash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;remove&lt;/code&gt; succeeds without a word: the folder and the repository's&lt;br&gt;
record of it are gone, and nothing else; the branch survives. If the&lt;br&gt;
worktree still had modified or untracked files, the same command&lt;br&gt;
refuses: &lt;code&gt;contains modified or untracked files, use --force to delete it&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What you shouldn't do is delete the folder by hand. Git copes with&lt;br&gt;
that too, though:&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 worktree add &lt;span class="nt"&gt;--detach&lt;/span&gt; ../scratch
Preparing worktree &lt;span class="o"&gt;(&lt;/span&gt;detached HEAD 542eaf6&lt;span class="o"&gt;)&lt;/span&gt;
HEAD is now at 542eaf6 wip: extract tokenizer
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; ../scratch
&lt;span class="nv"&gt;$ &lt;/span&gt;git worktree list
…/app      542eaf6 &lt;span class="o"&gt;[&lt;/span&gt;feature]
…/scratch  542eaf6 &lt;span class="o"&gt;(&lt;/span&gt;detached HEAD&lt;span class="o"&gt;)&lt;/span&gt; prunable
&lt;span class="nv"&gt;$ &lt;/span&gt;git worktree prune
&lt;span class="nv"&gt;$ &lt;/span&gt;git worktree list
…/app  542eaf6 &lt;span class="o"&gt;[&lt;/span&gt;feature]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The folder died; the record remained, flagged &lt;code&gt;prunable&lt;/code&gt;, until&lt;br&gt;
&lt;code&gt;git worktree prune&lt;/code&gt; collected it.&lt;/p&gt;

&lt;p&gt;One more state you'll meet eventually: a worktree on a USB stick or a&lt;br&gt;
network share isn't always mounted, and from the repository's side an&lt;br&gt;
unmounted folder looks exactly like a deleted one; a prune would sever&lt;br&gt;
it. &lt;code&gt;git worktree lock --reason "on the USB drive" ../usb&lt;/code&gt; protects&lt;br&gt;
it: a locked worktree can't be pruned, and &lt;code&gt;remove&lt;/code&gt; refuses, quoting&lt;br&gt;
your reason back at you.&lt;/p&gt;

&lt;p&gt;Moving is allowed, with one asymmetry. &lt;code&gt;git worktree move&lt;/code&gt; relocates a&lt;br&gt;
linked worktree cleanly, while moving the main repository folder by&lt;br&gt;
hand breaks every link; &lt;code&gt;git worktree repair&lt;/code&gt;, run from the new&lt;br&gt;
location, mends them.&lt;/p&gt;

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

&lt;p&gt;The post you're reading was written in a worktree. GitDesktop's&lt;br&gt;
repository has a website lane, and on my machine that lane lives in a&lt;br&gt;
linked worktree, so drafting a post never touches the branch the app&lt;br&gt;
work is standing on. The client managing those worktrees is&lt;br&gt;
&lt;a href="https://gitdesktop.app/features/" rel="noopener noreferrer"&gt;GitDesktop&lt;/a&gt;, which I build. Its &lt;strong&gt;Worktrees…&lt;/strong&gt; dialog&lt;br&gt;
carries the whole lifecycle (add, open, rename, lock, promote,&lt;br&gt;
delete), with the guards built in: deleting a dirty or locked&lt;br&gt;
worktree asks first, and a moved repository folder gets re-connected&lt;br&gt;
by its &lt;strong&gt;Repair links&lt;/strong&gt; button.&lt;/p&gt;

&lt;p&gt;Two of its choices map straight onto the rules above. Switch to a&lt;br&gt;
branch that lives in another worktree and you don't get the&lt;br&gt;
&lt;code&gt;already used by worktree&lt;/code&gt; error: the branch is badged, and choosing&lt;br&gt;
it opens that folder instead. And &lt;strong&gt;Promote to main workspace&lt;/strong&gt;&lt;br&gt;
collapses the sequence the one-checkout rule forces (remove the&lt;br&gt;
worktree first, then check its branch out in the main workspace)&lt;br&gt;
into one action, once the worktree is clean.&lt;/p&gt;

&lt;p&gt;Branches take turns only when you give them one folder to take turns&lt;br&gt;
in. The habit outlived the constraint by a decade.&lt;/p&gt;

</description>
      <category>git</category>
    </item>
    <item>
      <title>Update a Git branch without checking it out</title>
      <dc:creator>Evan</dc:creator>
      <pubDate>Mon, 07 Sep 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/thebguy/update-a-git-branch-without-checking-it-out-nd1</link>
      <guid>https://dev.to/thebguy/update-a-git-branch-without-checking-it-out-nd1</guid>
      <description>&lt;p&gt;Your pull request just merged. You're already on the next branch, and&lt;br&gt;
now your local &lt;code&gt;main&lt;/code&gt; is stale: the merge landed on the remote, not in&lt;br&gt;
your clone. Sooner or later you'll cut a branch from &lt;code&gt;main&lt;/code&gt;, rebase onto&lt;br&gt;
it, or just read it, and you'd rather all of that see today's history&lt;br&gt;
instead of last week's.&lt;/p&gt;

&lt;p&gt;The move everyone knows is to go visit: &lt;code&gt;git switch main&lt;/code&gt;, &lt;code&gt;git pull&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;git switch -&lt;/code&gt;. It works. It also checks out &lt;code&gt;main&lt;/code&gt; for the privilege of&lt;br&gt;
running one fast-forward, and &lt;a href="https://gitdesktop.app/blog/the-whole-loop-one-window/" rel="noopener noreferrer"&gt;a checkout stopped being free&lt;br&gt;
years ago&lt;/a&gt;: dev servers restart, watchers&lt;br&gt;
refire, generated files churn, and if &lt;code&gt;.gitignore&lt;/code&gt; changed in the meantime&lt;br&gt;
you come back to a wall of untracked files. Two context switches&lt;br&gt;
to move one pointer.&lt;/p&gt;

&lt;p&gt;Git can move a branch you're not standing on. It has been able to all along.&lt;/p&gt;
&lt;h2&gt;
  
  
  The colon in the refspec
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git fetch&lt;/code&gt; takes refspecs: &lt;code&gt;&amp;lt;source&amp;gt;:&amp;lt;destination&amp;gt;&lt;/code&gt;. The source names a&lt;br&gt;
ref on the remote; the destination names the ref in your repository that&lt;br&gt;
the fetched history lands in. The everyday &lt;code&gt;git fetch origin&lt;/code&gt; gets its&lt;br&gt;
refspec from your config: &lt;code&gt;+refs/heads/*:refs/remotes/origin/*&lt;/code&gt;, remote&lt;br&gt;
branches in, tracking refs out. Write a refspec by hand and the&lt;br&gt;
destination can be a local branch:&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 branch &lt;span class="nt"&gt;--show-current&lt;/span&gt;
feature
&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; main
e7d9e76 init
&lt;span class="nv"&gt;$ &lt;/span&gt;git fetch origin main:main
From …/origin
   e7d9e76..114c7a5  main       -&amp;gt; main
   e7d9e76..114c7a5  main       -&amp;gt; origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two updates from one fetch: &lt;code&gt;main:main&lt;/code&gt; moved your local branch, and&lt;br&gt;
because its source is covered by the configured refspec, Git updated the&lt;br&gt;
matching tracking ref &lt;code&gt;origin/main&lt;/code&gt; too. And nothing else moved 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;&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; main
114c7a5 Add lexer &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="c"&gt;#13)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git branch &lt;span class="nt"&gt;--show-current&lt;/span&gt;
feature
&lt;span class="nv"&gt;$ &lt;/span&gt;git status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;
&lt;span class="err"&gt;$&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fetch never touches your index or your working tree. No checkout, nothing&lt;br&gt;
restarted, and &lt;code&gt;main&lt;/code&gt; is current: &lt;code&gt;git switch -c next main&lt;/code&gt; now starts&lt;br&gt;
from today's history, and &lt;code&gt;git rebase main&lt;/code&gt; picks up the fresh base.&lt;/p&gt;

&lt;p&gt;The destination doesn't have to exist yet, and it doesn't have to match&lt;br&gt;
the source's name:&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 fetch origin main:hotfix-base
From …/origin
 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;new branch]      main       -&amp;gt; hotfix-base
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single fetch also takes as many refspecs as you care to give it, so&lt;br&gt;
several branches can come current in one trip.&lt;/p&gt;
&lt;h2&gt;
  
  
  The remote is optional
&lt;/h2&gt;

&lt;p&gt;If a plain &lt;code&gt;git fetch origin&lt;/code&gt; has already run (your hand, your editor, a&lt;br&gt;
background sync), the new commits are sitting in your repository with&lt;br&gt;
only the tracking ref pointing at them, and your local branch is the one&lt;br&gt;
thing still stale. No second network trip needed: a repository&lt;br&gt;
can fetch from itself.&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 fetch origin
From …/origin
   114c7a5..c21574c  main       -&amp;gt; origin/main
&lt;span class="nv"&gt;$ &lt;/span&gt;git fetch &lt;span class="nb"&gt;.&lt;/span&gt; origin/main:main
From &lt;span class="nb"&gt;.&lt;/span&gt;
   114c7a5..c21574c  origin/main -&amp;gt; main
&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; main
c21574c Add tests &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="c"&gt;#14)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.&lt;/code&gt; is the repository you're in, treated as its own remote. The first&lt;br&gt;
command moved the tracking ref over the network; the second copied it&lt;br&gt;
onto the branch without any. The ref update is the same either way, so&lt;br&gt;
everything that follows (the refusals, the fast-forward rule) applies to&lt;br&gt;
a fetch from &lt;code&gt;.&lt;/code&gt; exactly as it does to one from &lt;code&gt;origin&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The branch you're standing on is off limits
&lt;/h2&gt;

&lt;p&gt;The exception is the branch that's checked out. Switch to &lt;code&gt;main&lt;/code&gt; and&lt;br&gt;
try to update it the same way:&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 fetch origin main:main
fatal: refusing to fetch into branch &lt;span class="s1"&gt;'refs/heads/main'&lt;/span&gt; checked out at &lt;span class="s1"&gt;'…/repo'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;fatal&lt;/code&gt;: this aborts the whole fetch before anything transfers,&lt;br&gt;
tracking ref included.&lt;/p&gt;

&lt;p&gt;The refusal protects an agreement. A checkout is three things in&lt;br&gt;
sync: the branch ref, the index, and the working tree. Fetch moves refs&lt;br&gt;
and reconciles nothing, so aiming it at the checked-out branch would&lt;br&gt;
move one of the three and leave the other two describing a commit that&lt;br&gt;
is no longer &lt;code&gt;HEAD&lt;/code&gt;. There's a flag that overrides the refusal, and it&lt;br&gt;
makes a tidy demonstration of why you shouldn't:&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 fetch &lt;span class="nt"&gt;--update-head-ok&lt;/span&gt; origin main:main   &lt;span class="c"&gt;# demo only — clean tree required&lt;/span&gt;
From …/origin
   c21574c..0538f6b  main       -&amp;gt; main
   c21574c..0538f6b  main       -&amp;gt; origin/main
&lt;span class="nv"&gt;$ &lt;/span&gt;git status
On branch main
Your branch is up to &lt;span class="nb"&gt;date &lt;/span&gt;with &lt;span class="s1"&gt;'origin/main'&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;

Changes to be committed:
  &lt;span class="o"&gt;(&lt;/span&gt;use &lt;span class="s2"&gt;"git restore --staged &amp;lt;file&amp;gt;..."&lt;/span&gt; to unstage&lt;span class="o"&gt;)&lt;/span&gt;
    deleted:    docs.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a staged deletion you never made. The fetched commit added&lt;br&gt;
&lt;code&gt;docs.md&lt;/code&gt;; your index still describes the tree from before it;&lt;br&gt;
&lt;code&gt;git status&lt;/code&gt; reports the difference as your doing. Commit from here&lt;br&gt;
and you revert the change you just pulled. Provided the working tree&lt;br&gt;
is clean, &lt;code&gt;git reset --hard&lt;/code&gt; puts the three back in agreement by&lt;br&gt;
rebuilding the index and working tree to match the moved branch; with&lt;br&gt;
uncommitted work present it would destroy that work too. Then&lt;br&gt;
&lt;code&gt;git switch -&lt;/code&gt; back to &lt;code&gt;feature&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The flag exists for &lt;code&gt;git pull&lt;/code&gt;, which passes it on every fetch it runs.&lt;br&gt;
A stock pull doesn't need it: its fetch half only writes &lt;code&gt;origin/*&lt;/code&gt;, and&lt;br&gt;
the merge or rebase half is what moves your branch. But a pull carrying&lt;br&gt;
a refspec (&lt;code&gt;git pull origin main:main&lt;/code&gt;, for instance) does fetch&lt;br&gt;
straight into the checked-out branch. What makes that safe is that pull&lt;br&gt;
reconciles the index and working tree immediately afterward (its output&lt;br&gt;
even says "fast-forwarding your working tree"); that reconciliation is&lt;br&gt;
the step &lt;code&gt;--update-head-ok&lt;/code&gt; alone skips.&lt;/p&gt;

&lt;p&gt;The same refusal covers a branch checked out &lt;em&gt;anywhere&lt;/em&gt;. If &lt;code&gt;main&lt;/code&gt; lives&lt;br&gt;
in a linked worktree, fetching into it fails from the primary checkout&lt;br&gt;
too, and the message names the path holding 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 worktree add ../wt-main main
Preparing worktree &lt;span class="o"&gt;(&lt;/span&gt;checking out &lt;span class="s1"&gt;'main'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
HEAD is now at 0538f6b Add docs &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="c"&gt;#15)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git fetch origin main:main
fatal: refusing to fetch into branch &lt;span class="s1"&gt;'refs/heads/main'&lt;/span&gt; checked out at &lt;span class="s1"&gt;'…/wt-main'&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git worktree remove ../wt-main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nor is the refusal a quirk of fetch: &lt;code&gt;git branch -f&lt;/code&gt; declines to move a&lt;br&gt;
checked-out branch with the same reasoning. For an idle branch, though,&lt;br&gt;
the two are not interchangeable. &lt;code&gt;branch -f&lt;/code&gt; repoints the ref wherever&lt;br&gt;
you say, no questions asked; the refspec holds the fast-forward line&lt;br&gt;
you're about to meet. And for the branch you're on, the updater is&lt;br&gt;
the one you already know, &lt;code&gt;git pull&lt;/code&gt;, for exactly the&lt;br&gt;
reconciliation reason above.&lt;/p&gt;
&lt;h2&gt;
  
  
  When the branch has commits of its own
&lt;/h2&gt;

&lt;p&gt;A fast-forward is the only move a plain refspec will make. If &lt;code&gt;main&lt;/code&gt; has&lt;br&gt;
a commit the remote doesn't, because you committed to it by accident or&lt;br&gt;
you're carrying a local patch, the update is rejected:&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;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; main
c540dc1 wip: note to self
&lt;span class="nv"&gt;$ &lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--short&lt;/span&gt; origin/main
0538f6b
&lt;span class="nv"&gt;$ &lt;/span&gt;git fetch origin main:main
From …/origin
 &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;rejected]        main       -&amp;gt; main  &lt;span class="o"&gt;(&lt;/span&gt;non-fast-forward&lt;span class="o"&gt;)&lt;/span&gt;
   0538f6b..62fb195  main       -&amp;gt; origin/main
&lt;span class="nv"&gt;$ &lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--short&lt;/span&gt; origin/main
0538f6b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fast-forward discards nothing; anything else could. It's the same&lt;br&gt;
conservatism as the checkout refusal, applied to history instead of&lt;br&gt;
the working tree.&lt;/p&gt;

&lt;p&gt;Look at the two &lt;code&gt;rev-parse&lt;/code&gt; calls, though. The &lt;code&gt;! [rejected]&lt;/code&gt; line is&lt;br&gt;
truthful; the &lt;code&gt;origin/main&lt;/code&gt; line under it never took effect — the ref&lt;br&gt;
reads &lt;code&gt;0538f6b&lt;/code&gt; before and after, whatever the output says about&lt;br&gt;
&lt;code&gt;62fb195&lt;/code&gt;. On the Git these transcripts ran on (2.51), a rejected&lt;br&gt;
refspec takes the opportunistic tracking update down with it. The&lt;br&gt;
manual doesn't specify which way this goes, so another version may keep&lt;br&gt;
the tracking update; the habit that holds everywhere is to let&lt;br&gt;
&lt;code&gt;rev-parse&lt;/code&gt; tell you what moved after any rejection, and to run a plain&lt;br&gt;
&lt;code&gt;git fetch origin&lt;/code&gt; if you want the tracking ref current.&lt;/p&gt;

&lt;p&gt;You can insist. A leading &lt;code&gt;+&lt;/code&gt; on the refspec (or &lt;code&gt;--force&lt;/code&gt;)&lt;br&gt;
permits the non-fast-forward:&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 fetch origin +main:main
From …/origin
 + c540dc1...62fb195 main       -&amp;gt; main  &lt;span class="o"&gt;(&lt;/span&gt;forced update&lt;span class="o"&gt;)&lt;/span&gt;
   0538f6b..62fb195  main       -&amp;gt; origin/main
&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="s1"&gt;'main@{1}'&lt;/span&gt;
c540dc1 wip: note to self
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the tracking line finally sticking: it's the same&lt;br&gt;
&lt;code&gt;0538f6b..62fb195&lt;/code&gt; the rejection printed and canceled. And notice what&lt;br&gt;
the force is: a reset wearing fetch's clothing. &lt;code&gt;main&lt;/code&gt; now matches the&lt;br&gt;
remote, and your local commit is off the branch — not destroyed,&lt;br&gt;
&lt;code&gt;main@{1}&lt;/code&gt; still names it, but off the branch, silently. If the stray&lt;br&gt;
commit should keep a real name rather than a reflog entry, park it&lt;br&gt;
before the force: &lt;code&gt;git branch rescue main&lt;/code&gt;, then the forced fetch, and&lt;br&gt;
afterward &lt;code&gt;rescue&lt;/code&gt; points at your commit while &lt;code&gt;main&lt;/code&gt; matches the remote.&lt;/p&gt;

&lt;p&gt;If instead you meant to keep both sides on &lt;code&gt;main&lt;/code&gt;, you want a merge or&lt;br&gt;
a rebase, and those need a working tree. Any working tree satisfies&lt;br&gt;
them, including one that exists for thirty seconds. Say the local&lt;br&gt;
commit is deliberate this time, and the remote has moved again:&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 fetch origin
From …/origin
   62fb195..5a013dc  main       -&amp;gt; origin/main
&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; main
687bfb7 fix: local-only patch
&lt;span class="nv"&gt;$ &lt;/span&gt;git worktree add ../tmp main
Preparing worktree &lt;span class="o"&gt;(&lt;/span&gt;checking out &lt;span class="s1"&gt;'main'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
HEAD is now at 687bfb7 fix: local-only patch
&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; ../tmp merge &lt;span class="nt"&gt;--no-edit&lt;/span&gt; origin/main
Merge made by the &lt;span class="s1"&gt;'ort'&lt;/span&gt; strategy.
 release.md | 1 +
 1 file changed, 1 insertion&lt;span class="o"&gt;(&lt;/span&gt;+&lt;span class="o"&gt;)&lt;/span&gt;
 create mode 100644 release.md
&lt;span class="nv"&gt;$ &lt;/span&gt;git worktree remove ../tmp
&lt;span class="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt; main
a0ffdc9 Merge remote-tracking branch &lt;span class="s1"&gt;'origin/main'&lt;/span&gt;
687bfb7 fix: local-only patch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your own checkout never blinked. For the rebase preference,&lt;br&gt;
&lt;code&gt;git -C ../tmp rebase origin/main&lt;/code&gt; would have served the same way. Two&lt;br&gt;
things about where this leaves you: &lt;code&gt;main&lt;/code&gt; now carries a commit the&lt;br&gt;
remote lacks, so the next plain &lt;code&gt;main:main&lt;/code&gt; fetch is a rejection until&lt;br&gt;
you push; and whether a merge like this one will conflict is&lt;br&gt;
&lt;a href="https://gitdesktop.app/blog/preview-a-merge-before-you-run-it/" rel="noopener noreferrer"&gt;answerable before you run it&lt;/a&gt;,&lt;br&gt;
from the same seat.&lt;/p&gt;

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

&lt;p&gt;The feature this post has been circling ships in &lt;a href="https://gitdesktop.app/features/" rel="noopener noreferrer"&gt;GitDesktop&lt;/a&gt;,&lt;br&gt;
the Git client I work on. After a fetch, every branch row shows how far&lt;br&gt;
it sits ahead or behind its upstream, and a branch with commits to pull&lt;br&gt;
offers &lt;strong&gt;Update from origin/…&lt;/strong&gt; in its context menu: no switching, your&lt;br&gt;
checkout stays put. Under that item, in order: an ancestry check to&lt;br&gt;
classify the case, then &lt;code&gt;git fetch . origin/main:main&lt;/code&gt; when it's a&lt;br&gt;
fast-forward, and for a diverged branch the throwaway-worktree merge&lt;br&gt;
from above — created, merged, removed, a conflicted merge aborted so&lt;br&gt;
the branch is left exactly as it was. Aimed at the branch you're&lt;br&gt;
standing on, it does the one reasonable thing left and merges in place,&lt;br&gt;
conflicts and all, the way a plain pull would.&lt;/p&gt;

&lt;p&gt;A branch is a name for a commit, and a name can move without you&lt;br&gt;
standing on it. You check out a branch to work on it; updating it never&lt;br&gt;
required your presence.&lt;/p&gt;

</description>
      <category>git</category>
    </item>
    <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>
