diff --git a/README.md b/README.md index 9d801c1b..d55fd6fd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/default.nix b/default.nix index f9c678e6..292e41e7 100644 --- a/default.nix +++ b/default.nix @@ -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 "test@test.com" + git config --global init.defaultBranch main + ./test/test.sh + ./test/mergetool.sh + ''; installPhase = "touch $out"; }; treefmt = treefmtEval.config.build.check source; diff --git a/main/Main.hs b/main/Main.hs index 5a26f861..ba61d790 100644 --- a/main/Main.hs +++ b/main/Main.hs @@ -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 @@ -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 () @@ -47,6 +51,7 @@ data Nixfmt = Nixfmt { files :: [FilePath], width :: Width, check :: Bool, + mergetool :: Bool, quiet :: Bool, strict :: Bool, verify :: Bool, @@ -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 = @@ -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 ()) @@ -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 diff --git a/nixfmt.cabal b/nixfmt.cabal index 97e312a4..87530ef6 100644 --- a/nixfmt.cabal +++ b/nixfmt.cabal @@ -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 diff --git a/test/mergetool.sh b/test/mergetool.sh new file mode 100755 index 00000000..d585f240 --- /dev/null +++ b/test/mergetool.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# https://stackoverflow.com/a/246128/6605742 +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +# Self-verify this script with shellcheck +shellcheck "$0" + +# Allows using a local directory for temporary files, +# which can then be inspected after the run +if (( $# > 0 )); then + tmp=$(realpath "$1/tmp") + if [[ -e "$tmp" ]]; then + rm -rf "$tmp" + fi + mkdir -p "$tmp" +else + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' exit +fi + +shopt -s expand_aliases +if command -v cabal &> /dev/null; then + cd "$SCRIPT_DIR/.." + cabal build exe:nixfmt + nixfmtDir=$(dirname "$(cabal list-bin nixfmt)") + export PATH=$nixfmtDir:$PATH + # Otherwise assume we're in CI, where instead nixfmt will be prebuilt +fi + +setup() { + local name=$1 + + git init --quiet "$tmp/$name" + echo -e "\e[33mTesting $name in $tmp/$name\e[0m" + cd "$tmp/$name" || exit 1 + + git branch -m main + + # shellcheck disable=SC2016 + git config mergetool.nixfmt.cmd 'nixfmt --mergetool "$BASE" "$LOCAL" "$REMOTE" "$MERGED"' + git config mergetool.nixfmt.trustExitCode true +} + +# Successfully merges formatting-related merge conflicts +setup "success" + +# Poorly-formatted file +cat > a.nix </dev/null && exit 1 + +# Resolve it automatically (should work because it's only related to formatting) +git mergetool -t nixfmt . +git commit -q --no-edit + +echo -e "\e[32mSuccess!\e[0m" + + +# Test that it doesn't try to resolve non-Nix files +setup "non-Nix" + +# Non-Nix file +cat > a.md </dev/null && exit 1 + +# Resolve it automatically, should fail +git mergetool -t nixfmt . && exit 1 + +echo -e "\e[32mSuccessfully failed!\e[0m" + + +# Fails if any file is not valid Nix, error is merged +setup "invalid" + +# Invalid Nix files +cat > a.nix </dev/null && exit 1 + +# Resolve it automatically (should work because it's only related to formatting) +git mergetool -t nixfmt . && exit 1 + +echo -e "\e[32mSuccessfully failed!\e[0m" + + +# Fails if there's an non-formatting related merge conflict +setup "non-formatting" + +# Poorly-formatted file +cat > a.nix </dev/null && exit 1 + +# Resolve it automatically, shouldn't work +git mergetool -t nixfmt . && exit 1 + +echo -e "\e[32mSuccessfully failed!\e[0m"