Skip to content

Commit 8439892

Browse files
infinisiloxij
andcommitted
Implement --mergetool mode
Makes resolving formatting-related conflicts easier. Idea and docs are from @oxij's #277. Code and tests are from @infinisil. By implementing it in Haskell, it's faster (less process spawning) and trivially integrated into build and distribution. Co-Authored-By: Jan Malakhovski <[email protected]>
1 parent e825e95 commit 8439892

File tree

5 files changed

+325
-6
lines changed

5 files changed

+325
-6
lines changed

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,63 @@ null_ls.setup({
189189

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

192+
### git mergetool
193+
194+
Nixfmt provides a mode usable by [`git mergetool`](https://git-scm.com/docs/git-mergetool)
195+
via `--mergetool` that allows resolving formatting-related conflicts automatically in many cases.
196+
197+
It can be installed by any of these methods:
198+
199+
- For only for the current repo, run:
200+
```
201+
git config mergetool.nixfmt.cmd 'nixfmt --mergetool "$BASE" "$LOCAL" "$REMOTE" "$MERGED"'
202+
git config mergetool.nixfmt.trustExitCode true
203+
```
204+
- For all repos with a mutable config file, run
205+
```
206+
git config --global mergetool.nixfmt.cmd 'nixfmt --mergetool "$BASE" "$LOCAL" "$REMOTE" "$MERGED"'
207+
git config --global mergetool.nixfmt.trustExitCode true
208+
```
209+
- For all repos with a NixOS-provided config file, add this to your `configuration.nix`:
210+
```nix
211+
programs.git.config = {
212+
mergetool.nixfmt = {
213+
cmd = "nixfmt --mergetool \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"";
214+
trustExitCode = true;
215+
};
216+
};
217+
```
218+
- For all repos with a home-manager-provided config file, add this to your `home.nix`:
219+
```nix
220+
programs.git.extraConfig = {
221+
mergetool.nixfmt = {
222+
cmd = "nixfmt --mergetool \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"";
223+
trustExitCode = true;
224+
};
225+
};
226+
```
227+
228+
Then, when `git merge` or `git rebase` fails, run
229+
```
230+
git mergetool -t nixfmt .
231+
# or, only for some specific files
232+
git mergetool -t nixfmt FILE1 FILE2 FILE3
233+
```
234+
235+
and some `.nix` files will probably get merged automagically.
236+
237+
Note that files that `git` merges successfully even before `git mergetool`
238+
will be ignored by \`git mergetool\`.
239+
240+
If you don't like the result, run
241+
```
242+
git restore --merge .
243+
# or, only for some specific files
244+
git restore --merge FILE1 FILE2 FILE3
245+
```
246+
247+
to return back to the unmerged state.
248+
192249
## Development
193250

194251
### With Nix

default.nix

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,18 @@ let
9595
nativeBuildInputs = with pkgs; [
9696
shellcheck
9797
build
98+
gitMinimal
9899
];
99100
patchPhase = "patchShebangs .";
100-
buildPhase = "./test/test.sh";
101+
buildPhase = ''
102+
export HOME=$(mktemp -d)
103+
export PAGER=cat
104+
git config --global user.name "Test"
105+
git config --global user.email "[email protected]"
106+
git config --global init.defaultBranch main
107+
./test/test.sh
108+
./test/mergetool.sh
109+
'';
101110
installPhase = "touch $out";
102111
};
103112
treefmt = treefmtEval.config.build.check source;

main/Main.hs

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
{-# LANGUAGE DeriveDataTypeable #-}
2+
{-# LANGUAGE LambdaCase #-}
23
{-# LANGUAGE MultiWayIf #-}
34
{-# LANGUAGE NamedFieldPuns #-}
45
{-# LANGUAGE TemplateHaskell #-}
56

67
module Main where
78

8-
import Control.Monad (unless)
9+
import Control.Monad (forM, unless)
910
import Control.Monad.Trans.Class (lift)
11+
import Control.Monad.Trans.Except (ExceptT (ExceptT), runExceptT, throwE)
1012
import Control.Monad.Trans.State.Strict (StateT, evalStateT, get, put)
13+
import Data.Bifunctor (first)
1114
import Data.ByteString.Char8 (unpack)
1215
import Data.Either (lefts)
1316
import Data.FileEmbed
14-
import Data.List (isSuffixOf)
17+
import Data.List (intersperse, isSuffixOf)
1518
import Data.Maybe (fromMaybe)
1619
import Data.Text (Text)
17-
import qualified Data.Text.IO as TextIO (getContents, hPutStr, putStr)
20+
import qualified Data.Text.IO as TextIO (getContents, hGetContents, hPutStr, putStr)
1821
import Data.Version (showVersion)
1922
import GHC.IO.Encoding (utf8)
2023
import qualified Nixfmt
@@ -33,11 +36,12 @@ import System.Console.CmdArgs (
3336
import System.Directory (doesDirectoryExist, listDirectory)
3437
import System.Exit (ExitCode (..), exitFailure, exitSuccess)
3538
import System.FilePath ((</>))
36-
import System.IO (hPutStrLn, hSetEncoding, stderr)
39+
import System.IO (Handle, hGetContents, hPutStrLn, hSetEncoding, stderr)
3740
import System.IO.Atomic (withOutputFile)
3841
import System.IO.Utf8 (readFileUtf8, withUtf8StdHandles)
3942
import System.Posix.Process (exitImmediately)
4043
import System.Posix.Signals (Handler (..), installHandler, keyboardSignal)
44+
import System.Process (CreateProcess (std_out), StdStream (CreatePipe), createProcess, proc, waitForProcess)
4145

4246
type Result = Either String ()
4347

@@ -47,6 +51,7 @@ data Nixfmt = Nixfmt
4751
{ files :: [FilePath],
4852
width :: Width,
4953
check :: Bool,
54+
mergetool :: Bool,
5055
quiet :: Bool,
5156
strict :: Bool,
5257
verify :: Bool,
@@ -70,6 +75,7 @@ options =
7075
defaultWidth
7176
&= help (addDefaultHint defaultWidth "Maximum width in characters"),
7277
check = False &= help "Check whether files are formatted without modifying them",
78+
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",
7379
quiet = False &= help "Do not report errors",
7480
strict = False &= help "Enable a stricter formatting mode that isn't influenced as much by how the input is formatted",
7581
verify =
@@ -156,6 +162,14 @@ fileTarget path = Target (readFileUtf8 path) path atomicWriteFile
156162
-- Don't do anything if the file is already formatted
157163
atomicWriteFile False _ = mempty
158164

165+
-- | Writes to a (potentially non-existent) file path, but reads from a potentially separate handle
166+
copyTarget :: Handle -> FilePath -> Target
167+
copyTarget from to = Target (TextIO.hGetContents from) to atomicWriteFile
168+
where
169+
atomicWriteFile _ t = withOutputFile to $ \h -> do
170+
hSetEncoding h utf8
171+
TextIO.hPutStr h t
172+
159173
checkFileTarget :: FilePath -> Target
160174
checkFileTarget path = Target (readFileUtf8 path) path (const $ const $ pure ())
161175

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

200+
-- | `git mergetool` mode, which rejects all non-\`.nix\` files, while for \`.nix\` files it simply
201+
-- - Calls `nixfmt` on its first three inputs (the BASE, LOCAL and REMOTE versions to merge)
202+
-- - Runs `git merge-file` on the same inputs
203+
-- - Runs `nixfmt` on the result and stores it in the path given in the fourth argument (the MERGED file)
204+
mergeToolJob :: Nixfmt -> IO Result
205+
mergeToolJob opts@Nixfmt{files = [base, local, remote, merged]} = runExceptT $ do
206+
let formatter = toFormatter opts
207+
joinResults :: [Result] -> Result
208+
joinResults xs = case lefts xs of
209+
[] -> Right ()
210+
ls -> Left (mconcat (intersperse "\n" ls))
211+
inputs =
212+
[ ("base", base),
213+
("local", local),
214+
("remote", remote)
215+
]
216+
217+
unless (".nix" `isSuffixOf` merged) $
218+
throwE ("Skipping non-Nix file " ++ merged)
219+
220+
ExceptT $
221+
joinResults
222+
<$> forM
223+
inputs
224+
( \(name, path) -> do
225+
first (<> "pre-formatting the " <> name <> " version failed")
226+
<$> formatTarget formatter (fileTarget path)
227+
)
228+
229+
(_, Just out, _, process) <- do
230+
lift $
231+
createProcess
232+
(proc "git" ["merge-file", "--stdout", base, local, remote])
233+
{ std_out = CreatePipe
234+
}
235+
236+
lift (waitForProcess process) >>= \case
237+
ExitFailure code -> do
238+
output <- lift $ hGetContents out
239+
throwE $ output <> "`git merge-file` failed with exit code " <> show code <> "\n"
240+
ExitSuccess -> return ()
241+
242+
ExceptT $ formatTarget formatter (copyTarget out merged)
243+
mergeToolJob _ = return $ Left "--mergetool mode expects exactly 4 file arguments ($BASE, $LOCAL, $REMOTE, $MERGED)"
244+
186245
toJobs :: Nixfmt -> IO [IO Result]
187-
toJobs opts = map (toOperation opts $ toFormatter opts) <$> toTargets opts
246+
toJobs opts@Nixfmt{mergetool = False} = map (toOperation opts $ toFormatter opts) <$> toTargets opts
247+
toJobs opts@Nixfmt{mergetool = True} = return [mergeToolJob opts]
188248

189249
writeErrorBundle :: (String -> IO ()) -> Result -> IO Result
190250
writeErrorBundle doWrite result = do

nixfmt.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ executable nixfmt
3838
, unix >= 2.7.2 && < 2.9
3939
, text >= 1.2.3 && < 2.2
4040
, transformers
41+
, process
4142

4243
-- for System.IO.Atomic
4344
, directory >= 1.3.3 && < 1.4

0 commit comments

Comments
 (0)