Every merge conflict ends the same way. You fix the files, and then you have to stage them. And this is exactly the moment when a small accident happens: most of us type git add -u or git add . out of habit. Both commands stage everything that is modified, not only the files you have just resolved.

I’ve been bitten by this more than once. A set -x or a debug print in some unrelated file quietly goes into the merge commit together with the resolution. Nobody notices it, because nobody reads merge commits line by line. A few weeks later someone asks why the start script suddenly prints every command.

Git 2.56 finally has a proper answer for this:

git add --resolved

It stages only the files that were in conflict, and it refuses to stage anything while conflict markers are still left in them. Everything else in your working tree stays untouched.

In this article, I show how it works step by step, from an empty directory to the finished merge. But first, a few words about the release itself.

What’s new in Git 2.56

Git 2.56 is a big release, with many changes for daily work. I went through the release notes and tried the features that looked the most useful. Here is my short list:

  • git add --resolved stages only resolved conflicts. This article is about it.
  • git branch --delete-merged deletes local branches whose work has already landed in the upstream they track. It has safety rules: it keeps the checked-out branch, branches that are not merged yet, and branches you mark as “keep”.
  • git bisect --reset-when-found ends the bisect session by itself as soon as the bad commit is found.
  • git history drop removes one commit from the middle of the history, and the branches that depend on it follow.
  • git refs create/update/delete/rename is a readable and safe way to work with refs: no silent overwrites, and a compare-and-swap update.
  • git replay --linearize flattens a branch with merges in memory, even in a bare repository.
  • git log --graph no longer draws unrelated histories as if they were connected.
  • [includeIf "worktree:..."] gives you per-directory configuration that also works for linked worktrees.
  • fetch.followRemoteHEAD keeps origin/HEAD in sync with the remote in every repository, with one setting.
  • git repack --drop-filtered reclaims disk space in partial clones.

There are also small things that I like. For example, a typo in git push now gets a hint:

$ git push origin/main
fatal: 'origin/main' is not a valid push target
hint: Did you mean to use: git push origin main?

And -h now exits with code 0 in most commands (it was 129), so git status -h in a script no longer looks like an error. You can find the full list in the release notes.

How to get Git 2.56

Linux distributions need some time to ship a new Git version. On my Fedora, the system Git is still 2.55. I didn’t want to compile Git from source, so I used mise with the conda backend, which installs a prebuilt Git:

# mise.toml
[tools]
"conda:git" = "2.56.0"

[settings]
experimental = true   # the conda backend is still marked experimental
mise trust
mise install
mise exec -- git --version   # git version 2.56.0

It doesn’t replace the system Git: the new version is used only inside the project with this mise.toml.

The problem

After fixing the conflicts, the usual next step is git add -u or git add .. Both commands stage every modified tracked file. This includes the debugging line you’ve just added to an unrelated file, or the change you had in your working tree before the merge. Merge commits get much less review than normal commits, so these changes easily go unnoticed.

The safe alternative was to type every conflicted path by hand. But even then, nothing checked whether you had really removed all the conflict markers.

What git add –resolved does

  • It looks only at unmerged paths, the UU, AA, UD entries in git status.
  • It checks each of these files for leftover conflict markers.
  • If any file still has markers, it refuses to stage anything and shows the files that still need work.
  • Otherwise, it stages all of them.
  • Tracked files that are not in conflict are never staged.
  • You can narrow it with a path: git add --resolved config.ini.
  • It can’t be combined with -u or -A.

Step by step

Let’s try it on a tiny project. Every step below is recorded in a real terminal with Git 2.56.0. I write the files and resolve the conflicts in vim, exactly as I would do it myself. My prompt shows the directory and the current branch, for example shop (main) $, and shop (main|MERGING) $ while a merge is in progress.

Your commit IDs will be different from mine, which is normal.

Step 1: Create a repository

We start from nothing: a directory, git init, and git status to check that there are no commits yet. As soon as the repository exists, the prompt starts showing the branch.

mkdir shop
cd shop
git init
git status

Step 2: Write the first files

Three small files: a server config, a changelog, and a start script. In vim, i starts insert mode, Esc leaves it, and :wq saves the file and quits. git status --short shows all three files as untracked (??).

# config.ini
[server]
port = 8080
workers = 2

Step 3: The first commit

We stage the three files and commit them. This commit (a25ceb7 for me) is the starting point for both branches.

git add config.ini CHANGELOG.md app.sh
git commit -m "Initial version"
git log --oneline

Step 4: A feature branch

On a new branch feature, we move the server to port 9090 and add a line to the changelog. git branch marks the current branch with *, and git diff shows both changes before the commit.

In vim, /port jumps to the port line, $ goes to the end of the line, and ciw9090 replaces the word 8080. In the changelog, Go opens a new line at the end of the file.

git switch -c feature
git branch
git diff
git commit -am "Move the server to port 9090"

Step 5: Meanwhile, on main

Back on main, someone raises the number of workers to 8. This is the line right next to the port, so Git won’t be able to combine the two changes by itself. The graph shows how the two branches go in different directions.

git switch main
git commit -am "Use 8 workers by default"
git log --oneline --graph --all

Step 6: The trap

While debugging something else, I add set -x to app.sh (gg jumps to the first line, o opens a new line below it). It has nothing to do with the merge and must not be committed. Right now, it’s just a local modification ( M in git status --short), and this is completely normal. We all have such changes in our working trees.

Step 7: Merge, and two conflicts

git merge feature stops with conflicts in CHANGELOG.md and config.ini, and the prompt changes to main|MERGING. Look at git status: it shows two different groups:

  • Unmerged paths: the two conflicts.
  • Changes not staged for commit: our set -x in app.sh.

The whole point of this article is to keep these two groups apart until the very end.

Step 8: Resolve config.ini

Between <<<<<<< HEAD and ======= is our side (main), and between ======= and >>>>>>> feature is theirs. We want both changes: 8 workers from main and port 9090 from feature. In vim:

KeysWhat it does
/<<<<<<< Enterjump to the first marker
dddelete the <<<<<<< HEAD line
$ciw9090 Escon our port line, change 8080 to 9090
jjmove down to the ======= line
3dddelete ======= and their two lines
dddelete the >>>>>>> feature line
:wqsave and quit

The result:

[server]
port = 9090
workers = 8

Step 9: One file is still not resolved

Let’s say we forgot about the changelog and run git add --resolved right away. Git refuses and tells us which file still has markers:

$ git add --resolved
fatal: the following paths still have conflict markers:
	CHANGELOG.md

More importantly, it stages nothing at all, not even the config.ini we have already fixed. git status --short still shows both files as UU. It’s all or nothing, so you never end up with a half-staged resolution.

Step 10: What the old habit would do

For comparison, here is a dry run of git add -u. Besides the two conflicted files, it would happily stage app.sh with our set -x. This is exactly the accident from the beginning of the article. --dry-run only shows what would happen, nothing is staged.

$ git add -u --dry-run
add 'CHANGELOG.md'
add 'app.sh'
add 'config.ini'

Step 11: Stage one file you are sure about

--resolved accepts a path, so you can stage files one by one as you finish them. Now config.ini is staged (M ), CHANGELOG.md is still in conflict (UU), and app.sh is still an unstaged local change ( M).

git add --resolved config.ini
git status --short

Step 12: Resolve the changelog

In the changelog, we want to keep both new lines, so we only delete the three marker lines:

KeysWhat it does
/<<<<<<< Enter, ddfind and delete the first marker
/======= Enter, ddfind and delete the separator
/>>>>>>> Enter, ddfind and delete the last marker
:wqsave and quit

This time git add --resolved goes through. Both conflicted files are staged, and app.sh is still left alone:

$ git add --resolved
$ git status --short
M  CHANGELOG.md
 M app.sh
M  config.ini

Step 13: Finish the merge

git commit opens the editor with the prepared message Merge branch 'feature'. I like to add one line that explains how the conflict was resolved. Your future self will thank you. Then a few checks:

  • git log --oneline --graph: the merge commit a64a650 joins the two branches.
  • git diff --stat HEAD^1 HEAD: the merge changed only CHANGELOG.md and config.ini.
  • git status --short: app.sh is still modified and not committed, exactly as it should be.
$ git diff --stat HEAD^1 HEAD
 CHANGELOG.md | 1 +
 config.ini   | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)
$ git status --short
 M app.sh

Step 14: A small guard rail

--resolved is narrow on purpose, so Git doesn’t let you combine it with -u or -A, which would stage everything anyway:

$ git add --resolved -u
fatal: options '-u/--update' and '--resolved' cannot be used together

Things to know

  • Modify/delete conflicts have no markers. If one side deleted a file and the other side changed it, the file in your working tree is plain content. --resolved treats it as resolved and stages it as it is: it keeps the file if it’s there, and records the deletion if you have removed it. So decide whether to keep or delete the file before you run it.
  • The marker check is a safety net, not a proof. A resolution without markers can still be wrong, so build and test as usual.
  • It works not only for merge. It looks at the unmerged entries in the index, so it works the same way after rebase, cherry-pick, revert or stash pop.
  • Untracked files are never touched, so it’s also safe when you have scratch files lying around.

If you want to make it a habit, an alias helps:

[alias]
    resolved = add --resolved

After that, the flow is simple: git merge topic, fix the files, git resolved, git commit.

📬 More short notes like this one

I post practical DevOps and SRE notes, new tool features and small tricks in my Telegram channel DevOps & SRE notes.

Conclusion

git add --resolved is a small option, but it solves a real problem. It stages only what you have resolved, it refuses to stage anything while conflict markers are left, and it leaves the rest of your working tree alone. It’s a good replacement for git add -u after every conflict.

In summary: merge, resolve, git add --resolved, commit. And your debugging changes stay where they belong, in your working tree.

Links: Git 2.56.0 release notes · git add documentation