Merge pull request #3084 from git-lfs/pastelmobilesuit-migrate-export

Implement `migrate export` subcommand
This commit is contained in:
Preben Ingvaldsen 2018-07-03 13:00:00 -07:00 committed by GitHub
commit 22b9113e25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 883 additions and 9 deletions

@ -43,6 +43,10 @@ var (
// migrateCommitMessage is the message to use with the commit generated
// by the migrate command
migrateCommitMessage string
// exportRemote is the remote from which to download objects when
// performing an export
exportRemote string
)
// migrate takes the given command and arguments, *odb.ObjectDatabase, as well
@ -297,6 +301,11 @@ func init() {
importCmd.Flags().BoolVar(&migrateNoRewrite, "no-rewrite", false, "Add new history without rewriting previous")
importCmd.Flags().StringVarP(&migrateCommitMessage, "message", "m", "", "With --no-rewrite, an optional commit message")
exportCmd := NewCommand("export", migrateExportCommand)
exportCmd.Flags().BoolVar(&migrateVerbose, "verbose", false, "Verbose logging")
exportCmd.Flags().StringVar(&objectMapFilePath, "object-map", "", "Object map file")
exportCmd.Flags().StringVar(&exportRemote, "remote", "", "Remote from which to download objects")
RegisterCommand("migrate", nil, func(cmd *cobra.Command) {
cmd.PersistentFlags().StringVarP(&includeArg, "include", "I", "", "Include a list of paths")
cmd.PersistentFlags().StringVarP(&excludeArg, "exclude", "X", "", "Exclude a list of paths")
@ -306,6 +315,6 @@ func init() {
cmd.PersistentFlags().BoolVar(&migrateEverything, "everything", false, "Migrate all local references")
cmd.PersistentFlags().BoolVar(&migrateSkipFetch, "skip-fetch", false, "Assume up-to-date remote references.")
cmd.AddCommand(importCmd, info)
cmd.AddCommand(exportCmd, importCmd, info)
})
}

@ -0,0 +1,194 @@
package commands
import (
"fmt"
"os"
"path/filepath"
"github.com/git-lfs/git-lfs/errors"
"github.com/git-lfs/git-lfs/filepathfilter"
"github.com/git-lfs/git-lfs/git"
"github.com/git-lfs/git-lfs/git/githistory"
"github.com/git-lfs/git-lfs/git/odb"
"github.com/git-lfs/git-lfs/lfs"
"github.com/git-lfs/git-lfs/tasklog"
"github.com/git-lfs/git-lfs/tools"
"github.com/spf13/cobra"
)
func migrateExportCommand(cmd *cobra.Command, args []string) {
l := tasklog.NewLogger(os.Stderr)
defer l.Close()
db, err := getObjectDatabase()
if err != nil {
ExitWithError(err)
}
defer db.Close()
rewriter := getHistoryRewriter(cmd, db, l)
filter := rewriter.Filter()
if len(filter.Include()) <= 0 {
ExitWithError(errors.Errorf("fatal: one or more files must be specified with --include"))
}
tracked := trackedFromExportFilter(filter)
gitfilter := lfs.NewGitFilter(cfg)
opts := &githistory.RewriteOptions{
Verbose: migrateVerbose,
ObjectMapFilePath: objectMapFilePath,
BlobFn: func(path string, b *odb.Blob) (*odb.Blob, error) {
if filepath.Base(path) == ".gitattributes" {
return b, nil
}
ptr, err := lfs.DecodePointer(b.Contents)
if err != nil {
if errors.IsNotAPointerError(err) {
return b, nil
}
return nil, err
}
downloadPath, err := gitfilter.ObjectPath(ptr.Oid)
if err != nil {
return nil, err
}
return odb.NewBlobFromFile(downloadPath)
},
TreeCallbackFn: func(path string, t *odb.Tree) (*odb.Tree, error) {
if path != "/" {
// Ignore non-root trees.
return t, nil
}
ours := tracked
theirs, err := trackedFromAttrs(db, t)
if err != nil {
return nil, err
}
// Create a blob of the attributes that are optionally
// present in the "t" tree's .gitattributes blob, and
// union in the patterns that we've tracked.
//
// Perform this Union() operation each time we visit a
// root tree such that if the underlying .gitattributes
// is present and has a diff between commits in the
// range of commits to migrate, those changes are
// preserved.
blob, err := trackedToBlob(db, theirs.Clone().Union(ours))
if err != nil {
return nil, err
}
// Finally, return a copy of the tree "t" that has the
// new .gitattributes file included/replaced.
return t.Merge(&odb.TreeEntry{
Name: ".gitattributes",
Filemode: 0100644,
Oid: blob,
}), nil
},
UpdateRefs: true,
}
requireInRepo()
opts, err = rewriteOptions(args, opts, l)
if err != nil {
ExitWithError(err)
}
remote := cfg.Remote()
if cmd.Flag("remote").Changed {
remote = exportRemote
}
remoteURL := getAPIClient().Endpoints.RemoteEndpoint("download", remote).Url
if remoteURL == "" && cmd.Flag("remote").Changed {
ExitWithError(errors.Errorf("fatal: invalid remote %s provided", remote))
}
// If we have a valid remote, pre-download all objects using the Transfer Queue
if remoteURL != "" {
q := newDownloadQueue(getTransferManifestOperationRemote("Download", remote), remote)
gs := lfs.NewGitScanner(func(p *lfs.WrappedPointer, err error) {
if err != nil {
return
}
if !filter.Allows(p.Name) {
return
}
downloadPath, err := gitfilter.ObjectPath(p.Oid)
if err != nil {
return
}
if _, err := os.Stat(downloadPath); os.IsNotExist(err) {
q.Add(p.Name, downloadPath, p.Oid, p.Size)
}
})
gs.ScanRefs(opts.Include, opts.Exclude, nil)
q.Wait()
for _, err := range q.Errors() {
if err != nil {
ExitWithError(err)
}
}
}
// Perform the rewrite
if _, err := rewriter.Rewrite(opts); err != nil {
ExitWithError(err)
}
// Only perform `git-checkout(1) -f` if the repository is non-bare.
if bare, _ := git.IsBare(); !bare {
t := l.Waiter("migrate: checkout")
err := git.Checkout("", nil, true)
t.Complete()
if err != nil {
ExitWithError(err)
}
}
fetchPruneCfg := lfs.NewFetchPruneConfig(cfg.Git)
// Set our preservation time-window for objects existing on the remote to
// 0. Because the newly rewritten commits have not yet been pushed, some
// exported objects can still exist on the remote within the time window
// and thus will not be pruned from the cache.
fetchPruneCfg.FetchRecentRefsDays = 0
// Prune our cache
prune(fetchPruneCfg, false, false, true)
}
// trackedFromExportFilter returns an ordered set of strings where each entry
// is a line we intend to place in the .gitattributes file. It adds/removes the
// filter/diff/merge=lfs attributes based on patterns included/excluded in the
// given filter. Since `migrate export` removes files from Git LFS, it will
// remove attributes for included files, and add attributes for excluded files
func trackedFromExportFilter(filter *filepathfilter.Filter) *tools.OrderedSet {
tracked := tools.NewOrderedSet()
for _, include := range filter.Include() {
tracked.Add(fmt.Sprintf("%s text !filter !merge !diff", escapeAttrPattern(include)))
}
for _, exclude := range filter.Exclude() {
tracked.Add(fmt.Sprintf("%s filter=lfs diff=lfs merge=lfs -text", escapeAttrPattern(exclude)))
}
return tracked
}

@ -123,6 +123,29 @@ If `--message` is given, the new commit will be created with the provided
message. If no message is given, a commit message will be generated based on the
file arguments.
### EXPORT
The 'export' mode migrates Git LFS pointer files present in the Git history out
of Git LFS, converting them into their corresponding object files. It supports
all the core 'migrate' options and these additional ones:
* `--verbose`
Print the commit oid and filename of migrated files to STDOUT.
* `--object-map=<path>`
Write to 'path' a file with the mapping of each rewritten commit. The file
format is CSV with this pattern: `OLD-SHA`,`NEW-SHA`
* `--remote=<git-remote>`
Download LFS objects from the provided 'git-remote' during the export. If
not provided, defaults to 'origin'.
The 'export' mode requires at minimum a pattern provided with the `--include`
argument to specify which files to export. Files matching the `--include`
patterns will be removed from Git LFS, while files matching the `--exclude`
patterns will retain their Git LFS status. The export command will modify the
.gitattributes to set/unset any filepath patterns as given by those flags.
## INCLUDE AND EXCLUDE
You can configure Git LFS to only migrate tree entries whose pathspec matches

@ -3,6 +3,9 @@ package odb
import (
"bytes"
"io"
"os"
"github.com/git-lfs/git-lfs/errors"
)
// Blob represents a Git object of type "blob".
@ -27,6 +30,42 @@ func NewBlobFromBytes(contents []byte) *Blob {
}
}
// NewBlobFromFile returns a new *Blob that contains the contents of the file
// at location "path" on disk. NewBlobFromFile does not read the file ahead of
// time, and instead defers this task until encoding the blob to the object
// database.
//
// If the file cannot be opened or stat(1)-ed, an error will be returned.
//
// When the blob receives a function call Close(), the file will also be closed,
// and any error encountered in doing so will be returned from Close().
func NewBlobFromFile(path string) (*Blob, error) {
f, err := os.Open(path)
if err != nil {
return nil, errors.Wrapf(err, "git/odb: could not open: %s",
path)
}
stat, err := f.Stat()
if err != nil {
return nil, errors.Wrapf(err, "git/odb: could not stat %s",
path)
}
return &Blob{
Contents: f,
Size: stat.Size(),
closeFn: func() error {
if err := f.Close(); err != nil {
return errors.Wrapf(err,
"git/odb: could not close %s", path)
}
return nil
},
}, nil
}
// Type implements Object.ObjectType by returning the correct object type for
// Blobs, BlobObjectType.
func (b *Blob) Type() ObjectType { return BlobObjectType }

@ -89,7 +89,20 @@ func (s *GitScanner) ScanLeftToRemote(left string, cb GitScannerFoundPointer) er
}
s.mu.Unlock()
return scanRefsToChan(s, callback, left, "", s.opts(ScanLeftToRemoteMode))
return scanLeftRightToChan(s, callback, left, "", s.opts(ScanLeftToRemoteMode))
}
// ScanRefs through all commits reachable by refs contained in "include" and
// not reachable by any refs included in "excluded"
func (s *GitScanner) ScanRefs(include, exclude []string, cb GitScannerFoundPointer) error {
callback, err := firstGitScannerCallback(cb, s.FoundPointer)
if err != nil {
return err
}
opts := s.opts(ScanRefsMode)
opts.SkipDeletedBlobs = false
return scanRefsToChan(s, callback, include, exclude, opts)
}
// ScanRefRange scans through all commits from the given left and right refs,
@ -102,7 +115,7 @@ func (s *GitScanner) ScanRefRange(left, right string, cb GitScannerFoundPointer)
opts := s.opts(ScanRefsMode)
opts.SkipDeletedBlobs = false
return scanRefsToChan(s, callback, left, right, opts)
return scanLeftRightToChan(s, callback, left, right, opts)
}
// ScanRefWithDeleted scans through all objects in the given ref, including
@ -121,7 +134,7 @@ func (s *GitScanner) ScanRef(ref string, cb GitScannerFoundPointer) error {
opts := s.opts(ScanRefsMode)
opts.SkipDeletedBlobs = true
return scanRefsToChan(s, callback, ref, "", opts)
return scanLeftRightToChan(s, callback, ref, "", opts)
}
// ScanAll scans through all objects in the git repository.
@ -133,7 +146,7 @@ func (s *GitScanner) ScanAll(cb GitScannerFoundPointer) error {
opts := s.opts(ScanAllMode)
opts.SkipDeletedBlobs = false
return scanRefsToChan(s, callback, "", "", opts)
return scanLeftRightToChan(s, callback, "", "", opts)
}
// ScanTree takes a ref and returns WrappedPointer objects in the tree at that

@ -33,15 +33,16 @@ func (s *lockableNameSet) Check(blobSha string) (string, bool) {
func noopFoundLockable(name string) {}
// scanRefsToChan takes a ref and returns a channel of WrappedPointer objects
// for all Git LFS pointers it finds for that ref.
// scanRefsToChan scans through all commits reachable by refs contained in
// "include" and not reachable by any refs included in "excluded" and returns
// a channel of WrappedPointer objects for all Git LFS pointers it finds.
// Reports unique oids once only, not multiple times if >1 file uses the same content
func scanRefsToChan(scanner *GitScanner, pointerCb GitScannerFoundPointer, refLeft, refRight string, opt *ScanRefsOptions) error {
func scanRefsToChan(scanner *GitScanner, pointerCb GitScannerFoundPointer, include, exclude []string, opt *ScanRefsOptions) error {
if opt == nil {
panic("no scan ref options")
}
revs, err := revListShas([]string{refLeft, refRight}, nil, opt)
revs, err := revListShas(include, exclude, opt)
if err != nil {
return err
}
@ -91,6 +92,13 @@ func scanRefsToChan(scanner *GitScanner, pointerCb GitScannerFoundPointer, refLe
return nil
}
// scanLeftRightToChan takes a ref and returns a channel of WrappedPointer objects
// for all Git LFS pointers it finds for that ref.
// Reports unique oids once only, not multiple times if >1 file uses the same content
func scanLeftRightToChan(scanner *GitScanner, pointerCb GitScannerFoundPointer, refLeft, refRight string, opt *ScanRefsOptions) error {
return scanRefsToChan(scanner, pointerCb, []string{refLeft, refRight}, nil, opt)
}
// revListShas uses git rev-list to return the list of object sha1s
// for the given ref. If all is true, ref is ignored. It returns a
// channel from which sha1 strings can be read.

443
test/test-migrate-export.sh Executable file

@ -0,0 +1,443 @@
#!/usr/bin/env bash
. "test/test-migrate-fixtures.sh"
. "test/testlib.sh"
begin_test "migrate export (default branch)"
(
set -e
setup_multiple_local_branches_tracked
# Add b.md, a pointer existing only on master
base64 < /dev/urandom | head -c 160 > b.md
git add b.md
git commit -m "add b.md"
md_oid="$(calc_oid "$(cat a.md)")"
txt_oid="$(calc_oid "$(cat a.txt)")"
b_md_oid="$(calc_oid "$(cat b.md)")"
git checkout my-feature
md_feature_oid="$(calc_oid "$(cat a.md)")"
git checkout master
assert_pointer "refs/heads/master" "a.md" "$md_oid" "140"
assert_pointer "refs/heads/master" "a.txt" "$txt_oid" "120"
assert_pointer "refs/heads/master" "b.md" "$b_md_oid" "160"
assert_pointer "refs/heads/my-feature" "a.md" "$md_feature_oid" "30"
git lfs migrate export --include="*.md, *.txt"
refute_pointer "refs/heads/master" "a.md"
refute_pointer "refs/heads/master" "a.txt"
refute_pointer "refs/heads/master" "b.md"
assert_pointer "refs/heads/my-feature" "a.md" "$md_feature_oid" "30"
# b.md should be pruned as no pointer exists to reference it
refute_local_object "$b_md_oid" "160"
# Other objects should not be pruned as they're still referenced in `feature`
# by pointers
assert_local_object "$md_oid" "140"
assert_local_object "$txt_oid" "120"
assert_local_object "$md_feature_oid" "30"
master="$(git rev-parse refs/heads/master)"
feature="$(git rev-parse refs/heads/my-feature)"
master_attrs="$(git cat-file -p "$master:.gitattributes")"
feature_attrs="$(git cat-file -p "$feature:.gitattributes")"
echo "$master_attrs" | grep -q "*.md text !filter !merge !diff"
echo "$master_attrs" | grep -q "*.txt text !filter !merge !diff"
[ ! $(echo "$feature_attrs" | grep -q "*.md text !filter !merge !diff") ]
[ ! $(echo "$feature_attrs" | grep -q "*.txt text !filter !merge !diff") ]
)
end_test
begin_test "migrate export (with remote)"
(
set -e
setup_single_remote_branch_tracked
git push origin master
md_oid="$(calc_oid "$(cat a.md)")"
txt_oid="$(calc_oid "$(cat a.txt)")"
assert_pointer "refs/heads/master" "a.md" "$md_oid" "50"
assert_pointer "refs/heads/master" "a.txt" "$txt_oid" "30"
assert_pointer "refs/remotes/origin/master" "a.md" "$md_oid" "50"
assert_pointer "refs/remotes/origin/master" "a.txt" "$txt_oid" "30"
# Flush the cache to ensure all objects have to be downloaded
rm -rf .git/lfs/objects
git lfs migrate export --everything --include="*.md, *.txt"
refute_pointer "refs/heads/master" "a.md"
refute_pointer "refs/heads/master" "a.txt"
# All pointers have been exported, so all objects should be pruned
refute_local_object "$md_oid" "50"
refute_local_object "$txt_oid" "30"
master="$(git rev-parse refs/heads/master)"
master_attrs="$(git cat-file -p "$master:.gitattributes")"
echo "$master_attrs" | grep -q "*.md text !filter !merge !diff"
echo "$master_attrs" | grep -q "*.txt text !filter !merge !diff"
)
end_test
begin_test "migrate export (include/exclude args)"
(
set -e
setup_single_local_branch_tracked
md_oid="$(calc_oid "$(cat a.md)")"
txt_oid="$(calc_oid "$(cat a.txt)")"
assert_pointer "refs/heads/master" "a.txt" "$txt_oid" "120"
assert_pointer "refs/heads/master" "a.md" "$md_oid" "140"
git lfs migrate export --include="*" --exclude="a.md"
refute_pointer "refs/heads/master" "a.txt"
assert_pointer "refs/heads/master" "a.md" "$md_oid" "140"
refute_local_object "$txt_oid" "120"
assert_local_object "$md_oid" "140"
master="$(git rev-parse refs/heads/master)"
master_attrs="$(git cat-file -p "$master:.gitattributes")"
echo "$master_attrs" | grep -q "* text !filter !merge !diff"
echo "$master_attrs" | grep -q "a.md filter=lfs diff=lfs merge=lfs"
)
end_test
begin_test "migrate export (bare repository)"
(
set -e
setup_single_remote_branch_tracked
git push origin master
md_oid="$(calc_oid "$(cat a.md)")"
txt_oid="$(calc_oid "$(cat a.txt)")"
make_bare
assert_pointer "refs/heads/master" "a.txt" "$txt_oid" "30"
assert_pointer "refs/heads/master" "a.md" "$md_oid" "50"
git lfs migrate export --everything --include="*"
refute_pointer "refs/heads/master" "a.md"
refute_pointer "refs/heads/master" "a.txt"
# All pointers have been exported, so all objects should be pruned
refute_local_object "$md_oid" "50"
refute_local_object "$txt_oid" "30"
)
end_test
begin_test "migrate export (given branch)"
(
set -e
setup_multiple_local_branches_tracked
md_oid="$(calc_oid "$(cat a.md)")"
txt_oid="$(calc_oid "$(cat a.txt)")"
git checkout my-feature
md_feature_oid="$(calc_oid "$(cat a.md)")"
git checkout master
assert_pointer "refs/heads/my-feature" "a.md" "$md_feature_oid" "30"
assert_pointer "refs/heads/my-feature" "a.txt" "$txt_oid" "120"
assert_pointer "refs/heads/master" "a.md" "$md_oid" "140"
assert_pointer "refs/heads/master" "a.txt" "$txt_oid" "120"
git lfs migrate export --include="*.md,*.txt" my-feature
refute_pointer "refs/heads/my-feature" "a.md"
refute_pointer "refs/heads/my-feature" "a.txt"
refute_pointer "refs/heads/master" "a.md"
refute_pointer "refs/heads/master" "a.txt"
# No pointers left, so all objects should be pruned
refute_local_object "$md_feature_oid" "30"
refute_local_object "$txt_oid" "120"
refute_local_object "$md_oid" "140"
master="$(git rev-parse refs/heads/master)"
feature="$(git rev-parse refs/heads/my-feature)"
master_attrs="$(git cat-file -p "$master:.gitattributes")"
feature_attrs="$(git cat-file -p "$feature:.gitattributes")"
echo "$master_attrs" | grep -q "*.md text !filter !merge !diff"
echo "$master_attrs" | grep -q "*.txt text !filter !merge !diff"
echo "$feature_attrs" | grep -q "*.md text !filter !merge !diff"
echo "$feature_attrs" | grep -q "*.txt text !filter !merge !diff"
)
end_test
begin_test "migrate export (no filter)"
(
set -e
setup_multiple_local_branches_tracked
git lfs migrate export 2>&1 | tee migrate.log
if [ ${PIPESTATUS[0]} -eq 0 ]; then
echo >&2 "fatal: expected git lfs migrate export to fail, didn't"
exit 1
fi
grep "fatal: one or more files must be specified with --include" migrate.log
)
end_test
begin_test "migrate export (exclude remote refs)"
(
set -e
setup_single_remote_branch_tracked
md_oid="$(calc_oid "$(cat a.md)")"
txt_oid="$(calc_oid "$(cat a.txt)")"
git checkout refs/remotes/origin/master
md_remote_oid="$(calc_oid "$(cat a.md)")"
txt_remote_oid="$(calc_oid "$(cat a.txt)")"
git checkout master
assert_pointer "refs/heads/master" "a.md" "$md_oid" "50"
assert_pointer "refs/heads/master" "a.txt" "$txt_oid" "30"
assert_pointer "refs/remotes/origin/master" "a.md" "$md_remote_oid" "140"
assert_pointer "refs/remotes/origin/master" "a.txt" "$txt_remote_oid" "120"
git lfs migrate export --include="*.md,*.txt"
refute_pointer "refs/heads/master" "a.md"
refute_pointer "refs/heads/master" "a.txt"
refute_local_object "$md_oid" "50"
refute_local_object "$txt_oid" "30"
assert_pointer "refs/remotes/origin/master" "a.md" "$md_remote_oid" "140"
assert_pointer "refs/remotes/origin/master" "a.txt" "$txt_remote_oid" "120"
# Since these two objects exist on the remote, they should be removed with
# our prune operation
refute_local_object "$md_remote_oid" "140"
refute_local_object "$txt_remote_oid" "120"
master="$(git rev-parse refs/heads/master)"
remote="$(git rev-parse refs/remotes/origin/master)"
master_attrs="$(git cat-file -p "$master:.gitattributes")"
remote_attrs="$(git cat-file -p "$remote:.gitattributes")"
echo "$master_attrs" | grep -q "*.md text !filter !merge !diff"
echo "$master_attrs" | grep -q "*.txt text !filter !merge !diff"
[ ! $(echo "$remote_attrs" | grep -q "*.md text !filter !merge !diff") ]
[ ! $(echo "$remote_attrs" | grep -q "*.txt text !filter !merge !diff") ]
)
end_test
begin_test "migrate export (--skip-fetch)"
(
set -e
setup_single_remote_branch_tracked
md_master_oid="$(calc_oid "$(cat a.md)")"
txt_master_oid="$(calc_oid "$(cat a.txt)")"
git checkout refs/remotes/origin/master
md_remote_oid="$(calc_oid "$(cat a.md)")"
txt_remote_oid="$(calc_oid "$(cat a.txt)")"
git checkout master
git tag pseudo-remote "$(git rev-parse refs/remotes/origin/master)"
# Remove the refs/remotes/origin/master ref, and instruct 'git lfs migrate' to
# not fetch it.
git update-ref -d refs/remotes/origin/master
assert_pointer "refs/heads/master" "a.md" "$md_master_oid" "50"
assert_pointer "pseudo-remote" "a.md" "$md_remote_oid" "140"
assert_pointer "refs/heads/master" "a.txt" "$txt_master_oid" "30"
assert_pointer "pseudo-remote" "a.txt" "$txt_remote_oid" "120"
git lfs migrate export --skip-fetch --include="*.md,*.txt"
refute_pointer "refs/heads/master" "a.md"
refute_pointer "pseudo-remote" "a.md"
refute_pointer "refs/heads/master" "a.txt"
refute_pointer "pseudo-remote" "a.txt"
refute_local_object "$md_master_oid" "50"
refute_local_object "$md_remote_oid" "140"
refute_local_object "$txt_master_oid" "30"
refute_local_object "$txt_remote_oid" "120"
master="$(git rev-parse refs/heads/master)"
remote="$(git rev-parse pseudo-remote)"
master_attrs="$(git cat-file -p "$master:.gitattributes")"
remote_attrs="$(git cat-file -p "$remote:.gitattributes")"
echo "$master_attrs" | grep -q "*.md text !filter !merge !diff"
echo "$master_attrs" | grep -q "*.txt text !filter !merge !diff"
echo "$remote_attrs" | grep -q "*.md text !filter !merge !diff"
echo "$remote_attrs" | grep -q "*.txt text !filter !merge !diff"
)
end_test
begin_test "migrate export (include/exclude ref)"
(
set -e
setup_multiple_remote_branches_gitattrs
md_master_oid="$(calc_oid "$(cat a.md)")"
txt_master_oid="$(calc_oid "$(cat a.txt)")"
git checkout refs/remotes/origin/master
md_remote_oid="$(calc_oid "$(cat a.md)")"
txt_remote_oid="$(calc_oid "$(cat a.txt)")"
git checkout my-feature
md_feature_oid="$(calc_oid "$(cat a.md)")"
txt_feature_oid="$(calc_oid "$(cat a.txt)")"
git checkout master
git lfs migrate export \
--include="*.txt" \
--include-ref=refs/heads/my-feature \
--exclude-ref=refs/heads/master
assert_pointer "refs/heads/master" "a.md" "$md_master_oid" "21"
assert_pointer "refs/heads/master" "a.txt" "$txt_master_oid" "20"
assert_pointer "refs/remotes/origin/master" "a.md" "$md_remote_oid" "11"
assert_pointer "refs/remotes/origin/master" "a.txt" "$txt_remote_oid" "10"
assert_pointer "refs/heads/my-feature" "a.md" "$md_feature_oid" "31"
refute_pointer "refs/heads/my-feature" "a.txt"
# Master objects should not be pruned as they exist in unpushed commits
assert_local_object "$md_master_oid" "21"
assert_local_object "$txt_master_oid" "20"
# Remote master objects should be pruned as they exist in the remote
refute_local_object "$md_remote_oid" "11"
refute_local_object "$txt_remote_oid" "10"
# txt_feature_oid should be pruned as it's no longer a pointer, but
# md_feature_oid should remain as it's still a pointer in unpushed commits
assert_local_object "$md_feature_oid" "31"
refute_local_object "$txt_feature_oid" "30"
master="$(git rev-parse refs/heads/master)"
feature="$(git rev-parse refs/heads/my-feature)"
remote="$(git rev-parse refs/remotes/origin/master)"
master_attrs="$(git cat-file -p "$master:.gitattributes")"
remote_attrs="$(git cat-file -p "$remote:.gitattributes")"
feature_attrs="$(git cat-file -p "$feature:.gitattributes")"
[ ! $(echo "$master_attrs" | grep -q "*.txt text !filter !merge !diff") ]
[ ! $(echo "$remote_attrs" | grep -q "*.txt text !filter !merge !diff") ]
echo "$feature_attrs" | grep -q "*.txt text !filter !merge !diff"
)
end_test
begin_test "migrate export (--object-map)"
(
set -e
setup_multiple_local_branches_tracked
output_dir=$(mktemp -d)
git log --all --pretty='format:%H' > "${output_dir}/old_sha.txt"
git lfs migrate export --everything --include="*" --object-map "${output_dir}/object-map.txt"
git log --all --pretty='format:%H' > "${output_dir}/new_sha.txt"
paste -d',' "${output_dir}/old_sha.txt" "${output_dir}/new_sha.txt" > "${output_dir}/expected-map.txt"
diff -u <(sort "${output_dir}/expected-map.txt") <(sort "${output_dir}/object-map.txt")
)
end_test
begin_test "migrate export (--verbose)"
(
set -e
setup_multiple_local_branches_tracked
git lfs migrate export --everything --include="*" --verbose 2>&1 | grep -q "migrate: commit "
)
end_test
begin_test "migrate export (--remote)"
(
set -e
setup_single_remote_branch_tracked
git push origin master
md_oid="$(calc_oid "$(cat a.md)")"
txt_oid="$(calc_oid "$(cat a.txt)")"
assert_pointer "refs/heads/master" "a.md" "$md_oid" "50"
assert_pointer "refs/heads/master" "a.txt" "$txt_oid" "30"
# Flush the cache to ensure all objects have to be downloaded
rm -rf .git/lfs/objects
# Setup a new remote and invalidate the default
remote_url="$(git config --get remote.origin.url)"
git remote add zeta "$remote_url"
git remote set-url origin ""
git lfs migrate export --everything --remote="zeta" --include="*.md, *.txt"
refute_pointer "refs/heads/master" "a.md"
refute_pointer "refs/heads/master" "a.txt"
refute_local_object "$md_oid" "50"
refute_local_object "$txt_oid" "30"
)
end_test
begin_test "migrate export (invalid --remote)"
(
set -e
setup_single_remote_branch_tracked
git lfs migrate export --include="*" --remote="zz" 2>&1 | tee migrate.log
if [ ${PIPESTATUS[0]} -eq 0 ]; then
echo >&2 "fatal: expected git lfs migrate export to fail, didn't"
exit 1
fi
grep "fatal: invalid remote zz provided" migrate.log
)
end_test

@ -84,6 +84,33 @@ setup_local_branch_with_nested_gitattrs() {
git commit -m "add nested .gitattributes"
}
# setup_single_local_branch_tracked creates a repository as follows:
#
# A---B
# \
# refs/heads/master
#
# - Commit 'A' has 120, in a.txt and 140 in a.md, with both files tracked as
# pointers in Git LFS
setup_single_local_branch_tracked() {
set -e
reponame="migrate-single-remote-branch-with-attrs"
remove_and_create_local_repo "$reponame"
git lfs track "*.txt" "*.md"
git add .gitattributes
git commit -m "initial commit"
base64 < /dev/urandom | head -c 120 > a.txt
base64 < /dev/urandom | head -c 140 > a.md
git add a.txt a.md
git commit -m "add a.{txt,md}"
}
# setup_multiple_local_branches creates a repository as follows:
#
# B
@ -134,6 +161,36 @@ setup_multiple_local_branches_with_gitattrs() {
git commit -m "add .gitattributes"
}
# setup_multiple_local_branches_tracked creates a repo with exactly the same
# structure as in setup_multiple_local_branches, but with all files tracked by
# Git LFS
setup_multiple_local_branches_tracked() {
set -e
reponame="migrate-info-multiple-local-branches"
remove_and_create_local_repo "$reponame"
git lfs track "*.txt" "*.md"
git add .gitattributes
git commit -m "initial commit"
base64 < /dev/urandom | head -c 120 > a.txt
base64 < /dev/urandom | head -c 140 > a.md
git add a.txt a.md
git commit -m "add a.{txt,md}"
git checkout -b my-feature
base64 < /dev/urandom | head -c 30 > a.md
git add a.md
git commit -m "add an additional 30 bytes to a.md"
git checkout master
}
# setup_local_branch_with_space creates a repository as follows:
#
# A
@ -201,6 +258,34 @@ setup_single_remote_branch_with_gitattrs() {
git commit -m "add .gitattributes"
}
# Creates a repo identical to setup_single_remote_branch, except with *.md and
# *.txt files tracked by Git LFS
setup_single_remote_branch_tracked() {
set -e
reponame="migrate-info-single-remote-branch"
remove_and_create_remote_repo "$reponame"
git lfs track "*.md" "*.txt"
git add .gitattributes
git commit -m "initial commit"
base64 < /dev/urandom | head -c 120 > a.txt
base64 < /dev/urandom | head -c 140 > a.md
git add a.txt a.md
git commit -m "add a.{txt,md}"
git push origin master
base64 < /dev/urandom | head -c 30 > a.txt
base64 < /dev/urandom | head -c 50 > a.md
git add a.md a.txt
git commit -m "add an additional 30, 50 bytes to a.{txt,md}"
}
# setup_multiple_remote_branches creates a repository as follows:
#
# C
@ -247,6 +332,41 @@ setup_multiple_remote_branches() {
git checkout master
}
# Creates a repo identical to that in setup_multiple_remote_branches(), but
# with all files tracked by Git LFS
setup_multiple_remote_branches_gitattrs() {
set -e
reponame="migrate-info-exclude-remote-refs-given-branch"
remove_and_create_remote_repo "$reponame"
git lfs track "*.txt" "*.md"
git add .gitattributes
git commit -m "initial commit"
base64 < /dev/urandom | head -c 10 > a.txt
base64 < /dev/urandom | head -c 11 > a.md
git add a.txt a.md
git commit -m "add 10, 11 bytes, a.{txt,md}"
git push origin master
base64 < /dev/urandom | head -c 20 > a.txt
base64 < /dev/urandom | head -c 21 > a.md
git add a.txt a.md
git commit -m "add 20, 21 bytes, a.{txt,md}"
git checkout -b my-feature
base64 < /dev/urandom | head -c 30 > a.txt
base64 < /dev/urandom | head -c 31 > a.md
git add a.txt a.md
git commit -m "add 30, 31 bytes, a.{txt,md}"
git checkout master
}
# setup_single_local_branch_with_tags creates a repository as follows:
#
# A---B

@ -24,6 +24,31 @@ assert_pointer() {
fi
}
# refute_pointer confirms that the file in the repository for $path in the
# given $ref is _not_ a pointer.
#
# $ refute_pointer "master" "path/to/file"
refute_pointer() {
local ref="$1"
local path="$2"
gitblob=$(git ls-tree -lrz "$ref" |
while read -r -d $'\0' x; do
echo $x
done |
grep "$path" | cut -f 3 -d " ")
file=$(git cat-file -p $gitblob)
version="version https://git-lfs.github.com/spec/v[0-9]"
oid="oid sha256:[0-9a-f]\{32\}"
size="size [0-9]*"
regex="$version.*$oid.*$size"
if echo $file | grep -q "$regex"; then
exit 1
fi
}
# assert_local_object confirms that an object file is stored for the given oid &
# has the correct size
# $ assert_local_object "some-oid" size