Add a merge driver

Sometimes users have text files that they'd like to be able perform
merges on that they also want to store in Git LFS.  Let's add a custom
merge driver that users can use to merge these text files, either with
Git, or with a custom merge tool like the one shipped with Unity.
This commit is contained in:
brian m. carlson 2022-04-27 15:11:40 +00:00
parent aaca22358e
commit 402e958bfa
No known key found for this signature in database
GPG Key ID: 2D0C9BC12F82B3A1
5 changed files with 528 additions and 0 deletions

@ -658,6 +658,7 @@ MAN_ROFF_TARGETS = man/man1/git-lfs-checkout.1 \
man/man1/git-lfs-locks.1 \
man/man1/git-lfs-logs.1 \
man/man1/git-lfs-ls-files.1 \
man/man1/git-lfs-merge-driver.1 \
man/man1/git-lfs-migrate.1 \
man/man1/git-lfs-pointer.1 \
man/man1/git-lfs-post-checkout.1 \
@ -693,6 +694,7 @@ MAN_HTML_TARGETS = man/html/git-lfs-checkout.1.html \
man/html/git-lfs-locks.1.html \
man/html/git-lfs-logs.1.html \
man/html/git-lfs-ls-files.1.html \
man/html/git-lfs-merge-driver.1.html \
man/html/git-lfs-migrate.1.html \
man/html/git-lfs-pointer.1.html \
man/html/git-lfs-post-checkout.1.html \

@ -0,0 +1,147 @@
package commands
import (
"fmt"
"os"
"os/exec"
"github.com/git-lfs/git-lfs/v3/errors"
"github.com/git-lfs/git-lfs/v3/lfs"
"github.com/git-lfs/git-lfs/v3/subprocess"
"github.com/git-lfs/git-lfs/v3/tr"
"github.com/spf13/cobra"
)
var (
mergeDriverAncestor string
mergeDriverCurrent string
mergeDriverOther string
mergeDriverOutput string
mergeDriverProgram string
mergeDriverMarkerSize int
)
func mergeDriverCommand(cmd *cobra.Command, args []string) {
if len(mergeDriverAncestor) == 0 || len(mergeDriverCurrent) == 0 || len(mergeDriverOther) == 0 || len(mergeDriverOutput) == 0 {
Exit(tr.Tr.Get("the --ancestor, --current, --other, and --output options are mandatory"))
}
fileSpecifiers := make(map[string]string)
gf := lfs.NewGitFilter(cfg)
mergeProcessInput(gf, mergeDriverAncestor, fileSpecifiers, "O")
mergeProcessInput(gf, mergeDriverCurrent, fileSpecifiers, "A")
mergeProcessInput(gf, mergeDriverOther, fileSpecifiers, "B")
mergeProcessInput(gf, "", fileSpecifiers, "D")
fileSpecifiers["L"] = fmt.Sprintf("%d", mergeDriverMarkerSize)
if len(mergeDriverProgram) == 0 {
mergeDriverProgram = "git merge-file --stdout --marker-size=%L %A %O %B >%D"
}
status, err := processFiles(fileSpecifiers, mergeDriverProgram, mergeDriverOutput)
if err != nil {
ExitWithError(err)
}
os.Exit(status)
}
func processFiles(fileSpecifiers map[string]string, program string, outputFile string) (int, error) {
defer mergeCleanup(fileSpecifiers)
var exitStatus int
formattedMergeProgram := subprocess.FormatPercentSequences(mergeDriverProgram, fileSpecifiers)
cmd, err := subprocess.ExecCommand("sh", "-c", formattedMergeProgram)
if err != nil {
return -1, errors.New(tr.Tr.Get("failed to run merge program %q: %s", formattedMergeProgram, err))
}
err = cmd.Run()
// If it runs but exits nonzero, then that means there's conflicts
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
exitStatus = exitError.ProcessState.ExitCode()
} else {
return -1, errors.New(tr.Tr.Get("failed to run merge program %q: %s", formattedMergeProgram, err))
}
}
outputFp, err := os.OpenFile(outputFile, os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
return -1, err
}
defer outputFp.Close()
filename := fileSpecifiers["D"]
stat, err := os.Stat(filename)
if err != nil {
return -1, err
}
inputFp, err := os.OpenFile(filename, os.O_RDONLY|os.O_CREATE, 0600)
if err != nil {
return -1, err
}
defer inputFp.Close()
gf := lfs.NewGitFilter(cfg)
_, err = clean(gf, outputFp, inputFp, filename, stat.Size())
if err != nil {
return -1, err
}
return exitStatus, nil
}
func mergeCleanup(fileSpecifiers map[string]string) {
ids := []string{"A", "O", "B", "D"}
for _, id := range ids {
os.Remove(fileSpecifiers[id])
}
}
func mergeProcessInput(gf *lfs.GitFilter, filename string, fileSpecifiers map[string]string, tag string) {
file, err := lfs.TempFile(cfg, fmt.Sprintf("merge-driver-%s", tag))
if err != nil {
Exit(tr.Tr.Get("could not create temporary file when merging: %s", err))
}
defer file.Close()
fileSpecifiers[tag] = file.Name()
if len(filename) == 0 {
return
}
pointer, err := lfs.DecodePointerFromFile(filename)
if err != nil {
if errors.IsNotAPointerError(err) {
file.Close()
if err := lfs.CopyFileContents(cfg, filename, file.Name()); err != nil {
os.Remove(file.Name())
Exit(tr.Tr.Get("could not copy non-LFS content when merging: %s", err))
}
return
} else {
os.Remove(file.Name())
Exit(tr.Tr.Get("could not decode pointer when merging: %s", err))
}
}
cb, fp, err := gf.CopyCallbackFile("download", file.Name(), 1, 1)
if err != nil {
os.Remove(file.Name())
Exit(tr.Tr.Get("could not create callback: %s", err))
}
defer fp.Close()
_, err = gf.Smudge(file, pointer, file.Name(), true, getTransferManifestOperationRemote("download", cfg.Remote()), cb)
}
func init() {
RegisterCommand("merge-driver", mergeDriverCommand, func(cmd *cobra.Command) {
cmd.Flags().StringVarP(&mergeDriverAncestor, "ancestor", "", "", "file with the ancestor version")
cmd.Flags().StringVarP(&mergeDriverCurrent, "current", "", "", "file with the current version")
cmd.Flags().StringVarP(&mergeDriverOther, "other", "", "", "file with the other version")
cmd.Flags().StringVarP(&mergeDriverOutput, "output", "", "", "file with the output version")
cmd.Flags().StringVarP(&mergeDriverProgram, "program", "", "", "program to run to perform the merge")
cmd.Flags().IntVarP(&mergeDriverMarkerSize, "marker-size", "", 12, "merge marker size")
})
}

@ -0,0 +1,91 @@
git-lfs-merge-driver(1) -- Merge text-based LFS files
==============================================================
## SYNOPSIS
`git lfs merge-driver` [options]
## DESCRIPTION
Merge text files stored in Git LFS using the default Git merge machinery, or a
custom merge driver if specified. Note that this, in general, does not support
partial renames or copies because Git does not support them in this case.
This program is intended to be invoked automatically by Git and not by users
manually. See [CONFIGURATION] for details on the configuration required for
that.
## OPTIONS
* `--ancestor` <path>
Specify the file containing the ancestor revision.
* `--current` <path>
Specify the file containing the current revision.
* `--marker-size` <num>
Specify the conflict marker size as an integer.
* `--other` <path>
Specify the file containing the other revision.
* `--program` <program>
Specify a command, which is passed to the shell after substitution, that
performs the actual merge. If this is not specified, `git merge-file` is
invoked with appropriate arguments to perform the merge of the file.
See [CONFIGURATION] for the sequences which are substituted here.
## CONFIGURATION
Git allows the use of a custom merge driver for files based on the `merge`
attribute set in `.gitattributes`. By default, when using `git lfs track`, this
value is set to `lfs`.
Because Git LFS can be used to store both text and binary files and it isn't
always clear which behavior should be used, Git LFS does not enable this merge
driver by default. However, if you know that some or all of your files are text
files, then you can set the `merge` attribute for those files to `lfs-text` and
use `git config` to set the merge driver like so:
```console
$ git config merge.lfs-text.driver 'git lfs merge-driver --ancestor %O --current %A --other %B --marker-size %L --output %A'
```
This tells Git to invoke the custom Git LFS merge driver, which in turn uses
Git's merge machinery, to merge files where the `merge` attribute is set to
`lfs-text`. Note that `lfs-text` here is an example and any syntactically valid
value can be used.
If you are using a special type of file that needs rules different from Git's
standard merge machinery, you can also specify the `--program` option, which
is passed to `sh` after substituting its own percent-encoded escapes:
* `%A`: the current version
* `%B`: the other version
* `%D`: the destination version
* `%O`: the ancestor version
* `%L`: the conflict marker size
Note that the percent sign must typically be doubled to prevent Git from
substituting its own values here. Therefore, specifying the default behavior
explicitly looks like this:
```console
$ git config merge.lfs-text.driver \
'git lfs merge-driver --ancestor %O --current %A --other %B --marker-size %L --output %A --program '\''git merge-file --stdout --marker-size=%%L %%A %%O %%B >%%D'\'''
```
The exit status from the custom command should be zero on success or non-zero on
conflicts or other failure.
Note that if no merge driver is specified for the value of the `merge` attribute
(as is the case by default with `merge=lfs`), then the default Git merge
strategy is used. For LFS files, this means that Git will try to merge the
pointer files, which usually is not useful.
## SEE ALSO
git-merge(1), git-merge-file(1), gitattributes(5)
Part of the git-lfs(1) suite.

@ -81,6 +81,8 @@ commands and low level ("plumbing") commands.
Git clean filter that converts large files to pointers.
* git-lfs-filter-process(1):
Git process filter that converts between large files and pointers.
* git-lfs-merge-driver(1):
Merge text-based LFS files
* git-lfs-pointer(1):
Build and compare pointers.
* git-lfs-post-checkout(1):

286
t/t-merge-driver.sh Executable file

@ -0,0 +1,286 @@
#!/usr/bin/env bash
. "$(dirname "$0")/testlib.sh"
setup_successful_repo () {
if [ "$1" != "--track-later" ]
then
git lfs track '*.dat'
else
touch .gitattributes
fi
seq 1 10 > a.dat
git add .gitattributes *.dat
git commit -m 'Initial import'
git checkout -b other
# sed -i isn't portable.
sed -e 's/9/B/' a.dat > b.dat
mv b.dat a.dat
git add -u
git commit -m 'B'
if [ "$1" = "--track-later" ]
then
git lfs track '*.dat'
git add .gitattributes
git commit -m 'B2'
fi
git checkout --force main
sed -e 's/2/A/' a.dat > b.dat
mv b.dat a.dat
git add -u
git commit -m 'A'
if [ "$1" = "--track-later" ]
then
git lfs track '*.dat'
git add -u
git commit -m 'A2'
fi
}
setup_custom_repo () {
git lfs track '*.dat'
seq 1 10 > a.dat
git add .gitattributes *.dat
git commit -m 'Initial import'
git checkout -b other
# sed -i isn't portable.
sed -e 's/2/B/' -e 's/9/B/' a.dat > b.dat
mv b.dat a.dat
git add -u
git commit -m 'B'
git checkout main
sed -e 's/2/A/' -e 's/9/A/' a.dat > b.dat
mv b.dat a.dat
git add -u
git commit -m 'A'
}
setup_conflicting_repo () {
if [ "$1" != "--track-later" ]
then
git lfs track '*.dat'
else
touch .gitattributes
fi
seq 1 10 > a.dat
git add .gitattributes *.dat
git commit -m 'Initial import'
git checkout -b other
# sed -i isn't portable.
sed -e 's/3/B/' a.dat > b.dat
mv b.dat a.dat
git add -u
git commit -m 'B'
if [ "$1" = "--track-later" ]
then
git lfs track '*.dat'
git add .gitattributes
git commit -m 'B2'
fi
git checkout --force main
sed -e 's/2/A/' a.dat > b.dat
mv b.dat a.dat
git add -u
git commit -m 'A'
if [ "$1" = "--track-later" ]
then
git lfs track '*.dat'
git add -u
git commit -m 'A2'
fi
}
begin_test "merge-driver uses Git merge by default"
(
set -e
reponame="merge-driver-basic"
git init "$reponame"
cd "$reponame"
result="07b26d7b3123467282635a68fdf9b59e81269cf9faf12282cedf30f393a55e5b"
git config merge.lfs.driver 'git lfs merge-driver --ancestor %O --current %A --other %B --marker-size %L --output %A'
setup_successful_repo
git merge other
(
set -
echo 1
echo A
seq 3 8
echo B
echo 10
) > expected.dat
diff -u a.dat expected.dat
assert_pointer "main" "a.dat" "$result" 21
assert_local_object "$result" 21
)
end_test
begin_test "merge-driver uses Git merge when explicit"
(
set -e
reponame="merge-driver-explicit"
git init "$reponame"
cd "$reponame"
result="07b26d7b3123467282635a68fdf9b59e81269cf9faf12282cedf30f393a55e5b"
git config merge.lfs.driver 'git lfs merge-driver --ancestor %O --current %A --other %B --marker-size %L --output %A --program '\''git merge-file --stdout --marker-size=%%L %%A %%O %%B >%%D'\'''
git lfs track '*.dat'
setup_successful_repo
git merge other
(
set -e
echo 1
echo A
seq 3 8
echo B
echo 10
) > expected.dat
diff -u a.dat expected.dat
assert_pointer "main" "a.dat" "$result" 21
assert_local_object "$result" 21
)
end_test
begin_test "merge-driver uses custom driver when explicit"
(
set -e
reponame="merge-driver-custom"
git init "$reponame"
cd "$reponame"
result="07b26d7b3123467282635a68fdf9b59e81269cf9faf12282cedf30f393a55e5b"
git config merge.lfs.driver 'git lfs merge-driver --ancestor %O --current %A --other %B --marker-size %L --output %A --program '\''(sed -n 1,5p %%A; sed -n 6,10p %%B) >%%D'\'''
git lfs track '*.dat'
setup_custom_repo
git merge other
(
set -e
echo 1
echo A
seq 3 8
echo B
echo 10
) > expected.dat
diff -u a.dat expected.dat
assert_pointer "main" "a.dat" "$result" 21
assert_local_object "$result" 21
)
end_test
begin_test "merge-driver reports conflicts"
(
set -e
reponame="merge-driver-conflicts"
git init "$reponame"
cd "$reponame"
git config merge.lfs.driver 'git lfs merge-driver --ancestor %O --current %A --other %B --marker-size %L --output %A --program '\''git merge-file --stdout --marker-size=%%L %%A %%O %%B >%%D'\'''
git lfs track '*.dat'
setup_conflicting_repo
git merge other && exit 1
sed -e 's/<<<<<<<.*/<<<<<<</' -e 's/>>>>>>>.*/>>>>>>>/' a.dat > actual.dat
(
set -e
echo 1
echo "<<<<<<<"
echo A
echo 3
echo "======="
echo 2
echo B
echo ">>>>>>>"
seq 4 10
) > expected.dat
diff -u actual.dat expected.dat
)
end_test
begin_test "merge-driver gracefully handles non-pointer"
(
set -e
reponame="merge-driver-non-pointer"
git init "$reponame"
cd "$reponame"
result="07b26d7b3123467282635a68fdf9b59e81269cf9faf12282cedf30f393a55e5b"
git config merge.lfs.driver 'git lfs merge-driver --ancestor %O --current %A --other %B --marker-size %L --output %A'
setup_successful_repo --track-later
git merge other
(
set -
echo 1
echo A
seq 3 8
echo B
echo 10
) > expected.dat
diff -u a.dat expected.dat
assert_pointer "main" "a.dat" "$result" 21
assert_local_object "$result" 21
)
end_test
begin_test "merge-driver reports conflicts with non-pointer"
(
set -e
reponame="conflicts-non-pointer"
git init "$reponame"
cd "$reponame"
git config merge.lfs.driver 'git lfs merge-driver --ancestor %O --current %A --other %B --marker-size %L --output %A --program '\''git merge-file --stdout --marker-size=%%L %%A %%O %%B >%%D'\'''
setup_conflicting_repo --track-later
git merge other && exit 1
sed -e 's/<<<<<<<.*/<<<<<<</' -e 's/>>>>>>>.*/>>>>>>>/' a.dat > actual.dat
(
set -e
echo 1
echo "<<<<<<<"
echo A
echo 3
echo "======="
echo 2
echo B
echo ">>>>>>>"
seq 4 10
) > expected.dat
diff -u actual.dat expected.dat
)
end_test