Skip to content

Commit

Permalink
Add a merge driver
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
bk2204 committed May 10, 2022
1 parent aaca223 commit 402e958
Show file tree
Hide file tree
Showing 5 changed files with 528 additions and 0 deletions.
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down
147 changes: 147 additions & 0 deletions commands/command_merge_driver.go
Original file line number Diff line number Diff line change
@@ -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 changes: 91 additions & 0 deletions docs/man/git-lfs-merge-driver.1.ronn
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/man/git-lfs.1.ronn
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading

0 comments on commit 402e958

Please sign in to comment.