Skip to content

Implement --mergetool mode #283

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,63 @@ null_ls.setup({

This only works when `nixfmt-rfc-style` is installed (see above for installation instructions).

### git mergetool

Nixfmt provides a mode usable by [`git mergetool`](https://git-scm.com/docs/git-mergetool)
via `--mergetool` that allows resolving formatting-related conflicts automatically in many cases.

It can be installed by any of these methods:

- For only for the current repo, run:
```
git config mergetool.nixfmt.cmd 'nixfmt --mergetool "$BASE" "$LOCAL" "$REMOTE" "$MERGED"'
git config mergetool.nixfmt.trustExitCode true
```
- For all repos with a mutable config file, run
```
git config --global mergetool.nixfmt.cmd 'nixfmt --mergetool "$BASE" "$LOCAL" "$REMOTE" "$MERGED"'
git config --global mergetool.nixfmt.trustExitCode true
```
- For all repos with a NixOS-provided config file, add this to your `configuration.nix`:
```nix
programs.git.config = {
mergetool.nixfmt = {
cmd = "nixfmt --mergetool \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"";
trustExitCode = true;
};
};
```
- For all repos with a home-manager-provided config file, add this to your `home.nix`:
```nix
programs.git.extraConfig = {
mergetool.nixfmt = {
cmd = "nixfmt --mergetool \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"";
trustExitCode = true;
};
};
```

Then, when `git merge` or `git rebase` fails, run
```
git mergetool -t nixfmt .
# or, only for some specific files
git mergetool -t nixfmt FILE1 FILE2 FILE3
```

and some `.nix` files will probably get merged automagically.

Note that files that `git` merges successfully even before `git mergetool`
will be ignored by \`git mergetool\`.

If you don't like the result, run
```
git restore --merge .
# or, only for some specific files
git restore --merge FILE1 FILE2 FILE3
```

to return back to the unmerged state.

## Development

### With Nix
Expand Down
11 changes: 10 additions & 1 deletion default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,18 @@ let
nativeBuildInputs = with pkgs; [
shellcheck
build
gitMinimal
];
patchPhase = "patchShebangs .";
buildPhase = "./test/test.sh";
buildPhase = ''
export HOME=$(mktemp -d)
export PAGER=cat
git config --global user.name "Test"
git config --global user.email "[email protected]"
git config --global init.defaultBranch main
./test/test.sh
./test/mergetool.sh
'';
installPhase = "touch $out";
};
treefmt = treefmtEval.config.build.check source;
Expand Down
70 changes: 65 additions & 5 deletions main/Main.hs
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE TemplateHaskell #-}

module Main where

import Control.Monad (unless)
import Control.Monad (forM, unless)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Except (ExceptT (ExceptT), runExceptT, throwE)
import Control.Monad.Trans.State.Strict (StateT, evalStateT, get, put)
import Data.Bifunctor (first)
import Data.ByteString.Char8 (unpack)
import Data.Either (lefts)
import Data.FileEmbed
import Data.List (isSuffixOf)
import Data.List (intersperse, isSuffixOf)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text.IO as TextIO (getContents, hPutStr, putStr)
import qualified Data.Text.IO as TextIO (getContents, hGetContents, hPutStr, putStr)
import Data.Version (showVersion)
import GHC.IO.Encoding (utf8)
import qualified Nixfmt
Expand All @@ -33,11 +36,12 @@ import System.Console.CmdArgs (
import System.Directory (doesDirectoryExist, listDirectory)
import System.Exit (ExitCode (..), exitFailure, exitSuccess)
import System.FilePath ((</>))
import System.IO (hPutStrLn, hSetEncoding, stderr)
import System.IO (Handle, hGetContents, hPutStrLn, hSetEncoding, stderr)
import System.IO.Atomic (withOutputFile)
import System.IO.Utf8 (readFileUtf8, withUtf8StdHandles)
import System.Posix.Process (exitImmediately)
import System.Posix.Signals (Handler (..), installHandler, keyboardSignal)
import System.Process (CreateProcess (std_out), StdStream (CreatePipe), createProcess, proc, waitForProcess)

type Result = Either String ()

Expand All @@ -47,6 +51,7 @@ data Nixfmt = Nixfmt
{ files :: [FilePath],
width :: Width,
check :: Bool,
mergetool :: Bool,
quiet :: Bool,
strict :: Bool,
verify :: Bool,
Expand All @@ -70,6 +75,7 @@ options =
defaultWidth
&= help (addDefaultHint defaultWidth "Maximum width in characters"),
check = False &= help "Check whether files are formatted without modifying them",
mergetool = False &= help "Whether to run in git mergetool mode, see https://github.com/NixOS/nixfmt?tab=readme-ov-file#git-mergetool for more info",
quiet = False &= help "Do not report errors",
strict = False &= help "Enable a stricter formatting mode that isn't influenced as much by how the input is formatted",
verify =
Expand Down Expand Up @@ -156,6 +162,14 @@ fileTarget path = Target (readFileUtf8 path) path atomicWriteFile
-- Don't do anything if the file is already formatted
atomicWriteFile False _ = mempty

-- | Writes to a (potentially non-existent) file path, but reads from a potentially separate handle
copyTarget :: Handle -> FilePath -> Target
copyTarget from to = Target (TextIO.hGetContents from) to atomicWriteFile
where
atomicWriteFile _ t = withOutputFile to $ \h -> do
hSetEncoding h utf8
TextIO.hPutStr h t

checkFileTarget :: FilePath -> Target
checkFileTarget path = Target (readFileUtf8 path) path (const $ const $ pure ())

Expand Down Expand Up @@ -183,8 +197,54 @@ toWriteError :: Nixfmt -> String -> IO ()
toWriteError Nixfmt{quiet = False} = hPutStrLn stderr
toWriteError Nixfmt{quiet = True} = const $ return ()

-- | `git mergetool` mode, which rejects all non-\`.nix\` files, while for \`.nix\` files it simply
-- - Calls `nixfmt` on its first three inputs (the BASE, LOCAL and REMOTE versions to merge)
-- - Runs `git merge-file` on the same inputs
-- - Runs `nixfmt` on the result and stores it in the path given in the fourth argument (the MERGED file)
mergeToolJob :: Nixfmt -> IO Result
mergeToolJob opts@Nixfmt{files = [base, local, remote, merged]} = runExceptT $ do
let formatter = toFormatter opts
joinResults :: [Result] -> Result
joinResults xs = case lefts xs of
[] -> Right ()
ls -> Left (mconcat (intersperse "\n" ls))
inputs =
[ ("base", base),
("local", local),
("remote", remote)
]

unless (".nix" `isSuffixOf` merged) $
throwE ("Skipping non-Nix file " ++ merged)

ExceptT $
joinResults
<$> forM
inputs
( \(name, path) -> do
first (<> "pre-formatting the " <> name <> " version failed")
<$> formatTarget formatter (fileTarget path)
)

(_, Just out, _, process) <- do
lift $
createProcess
(proc "git" ["merge-file", "--stdout", base, local, remote])
{ std_out = CreatePipe
}

lift (waitForProcess process) >>= \case
ExitFailure code -> do
output <- lift $ hGetContents out
throwE $ output <> "`git merge-file` failed with exit code " <> show code <> "\n"
ExitSuccess -> return ()

ExceptT $ formatTarget formatter (copyTarget out merged)
mergeToolJob _ = return $ Left "--mergetool mode expects exactly 4 file arguments ($BASE, $LOCAL, $REMOTE, $MERGED)"

toJobs :: Nixfmt -> IO [IO Result]
toJobs opts = map (toOperation opts $ toFormatter opts) <$> toTargets opts
toJobs opts@Nixfmt{mergetool = False} = map (toOperation opts $ toFormatter opts) <$> toTargets opts
toJobs opts@Nixfmt{mergetool = True} = return [mergeToolJob opts]

writeErrorBundle :: (String -> IO ()) -> Result -> IO Result
writeErrorBundle doWrite result = do
Expand Down
1 change: 1 addition & 0 deletions nixfmt.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ executable nixfmt
, unix >= 2.7.2 && < 2.9
, text >= 1.2.3 && < 2.2
, transformers
, process

-- for System.IO.Atomic
, directory >= 1.3.3 && < 1.4
Expand Down
Loading