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:
parent
aaca22358e
commit
402e958bfa
2
Makefile
2
Makefile
@ -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 \
|
||||
|
147
commands/command_merge_driver.go
Normal file
147
commands/command_merge_driver.go
Normal file
@ -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")
|
||||
})
|
||||
}
|
91
docs/man/git-lfs-merge-driver.1.ronn
Normal file
91
docs/man/git-lfs-merge-driver.1.ronn
Normal file
@ -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
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
|
Loading…
Reference in New Issue
Block a user