From a75d9c48c7b99ca549c3761025e498fecc075410 Mon Sep 17 00:00:00 2001 From: Steve Streeting Date: Wed, 8 Jun 2016 17:03:05 +0100 Subject: [PATCH 1/5] First pass of tus.io resumable upload adapter --- transfer/adapterbase.go | 18 ++++ transfer/basic_download.go | 16 +--- transfer/tus_upload.go | 166 +++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 transfer/tus_upload.go diff --git a/transfer/adapterbase.go b/transfer/adapterbase.go index fa856c2f..ec548ec3 100644 --- a/transfer/adapterbase.go +++ b/transfer/adapterbase.go @@ -120,3 +120,21 @@ func (a *adapterBase) worker(workerNum int) { tracerx.Printf("xfer: adapter %q worker %d stopping", a.Name(), workerNum) a.workerWait.Done() } + +func advanceCallbackProgress(cb TransferProgressCallback, t *Transfer, numBytes int64) { + if cb != nil { + // Must split into max int sizes since read count is int + const maxInt = int(^uint(0) >> 1) + for read := int64(0); read < numBytes; { + remainder := numBytes - read + if remainder > int64(maxInt) { + read += int64(maxInt) + cb(t.Name, t.Object.Size, read, maxInt) + } else { + read += remainder + cb(t.Name, t.Object.Size, read, int(remainder)) + } + + } + } +} diff --git a/transfer/basic_download.go b/transfer/basic_download.go index 3ed29e17..cfe039b1 100644 --- a/transfer/basic_download.go +++ b/transfer/basic_download.go @@ -146,21 +146,7 @@ func (a *basicDownloadAdapter) download(t *Transfer, cb TransferProgressCallback } if rangeRequestOk { tracerx.Printf("xfer: server accepted resume download request: %q from byte %d", t.Object.Oid, fromByte) - // Advance progress callback; must split into max int sizes though - if cb != nil { - const maxInt = int(^uint(0) >> 1) - for read := int64(0); read < fromByte; { - remainder := fromByte - read - if remainder > int64(maxInt) { - read += int64(maxInt) - cb(t.Name, t.Object.Size, read, maxInt) - } else { - read += remainder - cb(t.Name, t.Object.Size, read, int(remainder)) - } - - } - } + advanceCallbackProgress(cb, t, fromByte) } else { // Abort resume, perform regular download tracerx.Printf("xfer: failed to resume download for %q from byte %d: %s. Re-downloading from start", t.Object.Oid, fromByte, failReason) diff --git a/transfer/tus_upload.go b/transfer/tus_upload.go new file mode 100644 index 00000000..8be8c730 --- /dev/null +++ b/transfer/tus_upload.go @@ -0,0 +1,166 @@ +package transfer + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "strconv" + + "github.com/github/git-lfs/api" + "github.com/github/git-lfs/errutil" + "github.com/github/git-lfs/httputil" + "github.com/github/git-lfs/progress" + "github.com/rubyist/tracerx" +) + +const ( + TusAdapterName = "tus" + TusVersion = "1.0.0" +) + +// Adapter for tus.io protocol resumaable uploads +type tusUploadAdapter struct { + *adapterBase +} + +func (a *tusUploadAdapter) ClearTempStorage() error { + // nothing to do, all temp state is on the server end + return nil +} + +func (a *tusUploadAdapter) DoTransfer(t *Transfer, cb TransferProgressCallback, authOkFunc func()) error { + rel, ok := t.Object.Rel("upload") + if !ok { + return fmt.Errorf("No upload action for this object.") + } + + // Note not supporting the Creation extension since the batch API generates URLs + // Also not supporting Concatenation to support parallel uploads of chunks; forward only + + // 1. Send HEAD request to determine upload start point + // Request must include Tus-Resumable header (version) + tracerx.Printf("xfer: sending tus.io HEAD request for %q", t.Object.Oid) + req, err := httputil.NewHttpRequest("HEAD", rel.Href, rel.Header) + if err != nil { + return err + } + req.Header.Set("Tus-Resumable", TusVersion) + res, err := httputil.DoHttpRequest(req, true) + if err != nil { + return errutil.NewRetriableError(err) + } + + // Response will contain Upload-Offset if supported + offHdr := res.Header.Get("Upload-Offset") + if len(offHdr) == 0 { + return fmt.Errorf("Missing Upload-Offset header from tus.io HEAD response at %q, contact server admin", rel.Href) + } + offset, err := strconv.ParseInt(offHdr, 10, 64) + if err != nil || offset < 0 { + return fmt.Errorf("Invalid Upload-Offset value %q in response from tus.io HEAD at %q, contact server admin", offHdr, rel.Href) + } + // Upload-Offset=size means already completed (skip) + // Batch API will probably already detect this, but handle just in case + if offset >= t.Object.Size { + tracerx.Printf("xfer: tus.io HEAD offset %d indicates %q is already fully uploaded, skipping", offset, t.Object.Oid) + advanceCallbackProgress(cb, t, t.Object.Size) + return nil + } + + // Open file for uploading + f, err := os.OpenFile(t.Path, os.O_RDONLY, 0644) + if err != nil { + return errutil.Error(err) + } + defer f.Close() + + // Upload-Offset=0 means start from scratch, but still send PATCH + if offset == 0 { + tracerx.Printf("xfer: tus.io uploading %q from start", t.Object.Oid) + } else { + tracerx.Printf("xfer: tus.io resuming upload %q from %d", t.Object.Oid, offset) + advanceCallbackProgress(cb, t, offset) + _, err := f.Seek(offset, os.SEEK_CUR) + if err != nil { + return errutil.Error(err) + } + } + + // 2. Send PATCH request with byte start point (even if 0) in Upload-Offset + // Response status must be 204 + // Response Upload-Offset must be request Upload-Offset plus sent bytes + // Response may include Upload-Expires header in which case check not passed + + tracerx.Printf("xfer: sending tus.io PATCH request for %q", t.Object.Oid) + req, err = httputil.NewHttpRequest("PATCH", rel.Href, rel.Header) + if err != nil { + return err + } + req.Header.Set("Tus-Resumable", TusVersion) + req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10)) + req.Header.Set("Content-Type", "application/offset+octet-stream") + req.Header.Set("Content-Length", strconv.FormatInt(t.Object.Size-offset, 10)) + req.ContentLength = t.Object.Size - offset + + // Ensure progress callbacks made while uploading + // Wrap callback to give name context + ccb := func(totalSize int64, readSoFar int64, readSinceLast int) error { + if cb != nil { + return cb(t.Name, totalSize, readSoFar, readSinceLast) + } + return nil + } + var reader io.Reader + reader = &progress.CallbackReader{ + C: ccb, + TotalSize: t.Object.Size, + Reader: f, + } + + // Signal auth was ok on first read; this frees up other workers to start + if authOkFunc != nil { + reader = newStartCallbackReader(reader, func(*startCallbackReader) { + authOkFunc() + }) + } + + req.Body = ioutil.NopCloser(reader) + + res, err = httputil.DoHttpRequest(req, true) + if err != nil { + return errutil.NewRetriableError(err) + } + httputil.LogTransfer("lfs.data.upload", res) + + // A status code of 403 likely means that an authentication token for the + // upload has expired. This can be safely retried. + if res.StatusCode == 403 { + return errutil.NewRetriableError(err) + } + + if res.StatusCode > 299 { + return errutil.Errorf(nil, "Invalid status for %s: %d", httputil.TraceHttpReq(req), res.StatusCode) + } + + io.Copy(ioutil.Discard, res.Body) + res.Body.Close() + + return api.VerifyUpload(t.Object) +} + +func init() { + newfunc := func(name string, dir Direction) TransferAdapter { + switch dir { + case Upload: + bu := &tusUploadAdapter{newAdapterBase(name, dir, nil)} + // self implements impl + bu.transferImpl = bu + return bu + case Download: + panic("Should never ask tus.io to download") + } + return nil + } + RegisterNewTransferAdapterFunc(TusAdapterName, Upload, newfunc) +} From 44cf589d8fff9ca330a013577c3be55bd35f7e32 Mon Sep 17 00:00:00 2001 From: Steve Streeting Date: Fri, 10 Jun 2016 14:43:58 +0100 Subject: [PATCH 2/5] Add tests for tus.io resume upload support --- test/cmd/lfstest-gitserver.go | 157 +++++++++++++++++++++++++++++++++- test/test-resume-tus.sh | 69 +++++++++++++++ 2 files changed, 222 insertions(+), 4 deletions(-) create mode 100755 test/test-resume-tus.sh diff --git a/test/cmd/lfstest-gitserver.go b/test/cmd/lfstest-gitserver.go index 0f2c1c11..bba5d22e 100644 --- a/test/cmd/lfstest-gitserver.go +++ b/test/cmd/lfstest-gitserver.go @@ -311,7 +311,18 @@ func lfsBatchHandler(w http.ResponseWriter, r *http.Request, repo string) { res := []lfsObject{} testingChunked := testingChunkedTransferEncoding(r) + testingTus := testingTusUploadInBatchReq(r) + testingTusInterrupt := testingTusUploadInterruptedInBatchReq(r) var transferChoice string + if testingTus { + for _, t := range objs.Transfers { + if t == "tus" { + transferChoice = "tus" + break + } + + } + } for _, obj := range objs.Objects { action := objs.Operation @@ -359,6 +370,9 @@ func lfsBatchHandler(w http.ResponseWriter, r *http.Request, repo string) { if testingChunked { o.Actions[action].Header["Transfer-Encoding"] = "chunked" } + if testingTusInterrupt { + o.Actions[action].Header["Lfs-Tus-Interrupt"] = "true" + } res = append(res, o) } @@ -379,6 +393,7 @@ func lfsBatchHandler(w http.ResponseWriter, r *http.Request, repo string) { // Persistent state across requests var batchResumeFailFallbackStorageAttempts = 0 +var tusStorageAttempts = 0 // handles any /storage/{oid} requests func storageHandler(w http.ResponseWriter, r *http.Request) { @@ -425,6 +440,7 @@ func storageHandler(w http.ResponseWriter, r *http.Request) { hash := sha256.New() buf := &bytes.Buffer{} + io.Copy(io.MultiWriter(hash, buf), r.Body) oid := hex.EncodeToString(hash.Sum(nil)) if !strings.HasSuffix(r.URL.Path, "/"+oid) { @@ -481,6 +497,98 @@ func storageHandler(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(404) + case "HEAD": + // tus.io + if len(r.Header.Get("Tus-Resumable")) == 0 { + log.Fatal("Missing Tus-Resumable header in request") + w.WriteHeader(400) + return + } + parts := strings.Split(r.URL.Path, "/") + oid := parts[len(parts)-1] + if by, ok := largeObjects.GetIncomplete(repo, oid); ok { + w.Header().Set("Upload-Offset", strconv.FormatInt(int64(len(by)), 10)) + } else { + w.Header().Set("Upload-Offset", "0") + } + w.WriteHeader(200) + case "PATCH": + // tus.io + if len(r.Header.Get("Tus-Resumable")) == 0 { + log.Fatal("Missing Tus-Resumable header in request") + w.WriteHeader(400) + return + } + parts := strings.Split(r.URL.Path, "/") + oid := parts[len(parts)-1] + + offsetHdr := r.Header.Get("Upload-Offset") + offset, err := strconv.ParseInt(offsetHdr, 10, 64) + if err != nil { + log.Fatal("Unable to parse Upload-Offset header in request: ", err) + w.WriteHeader(400) + return + } + hash := sha256.New() + buf := &bytes.Buffer{} + out := io.MultiWriter(hash, buf) + + if by, ok := largeObjects.GetIncomplete(repo, oid); ok { + if offset != int64(len(by)) { + log.Fatal(fmt.Sprintf("Incorrect offset in request, got %d expected %d", offset, len(by))) + w.WriteHeader(400) + return + } + _, err := out.Write(by) + if err != nil { + log.Fatal("Error reading incomplete bytes from store: ", err) + w.WriteHeader(500) + return + } + largeObjects.DeleteIncomplete(repo, oid) + log.Printf("Resuming upload of %v at byte %d", oid, offset) + } + + // As a test, we intentionally break the upload from byte 0 by only + // reading some bytes the quitting & erroring, this forces a resume + // any offset > 0 will work ok + var copyErr error + if r.Header.Get("Lfs-Tus-Interrupt") == "true" && offset == 0 { + chdr := r.Header.Get("Content-Length") + contentLen, err := strconv.ParseInt(chdr, 10, 64) + if err != nil { + log.Fatal(fmt.Sprintf("Invalid Content-Length %q", chdr)) + w.WriteHeader(400) + return + } + truncated := contentLen / 3 + _, _ = io.CopyN(out, r.Body, truncated) + r.Body.Close() + copyErr = fmt.Errorf("Simulated copy error") + } else { + _, copyErr = io.Copy(out, r.Body) + } + if copyErr != nil { + b := buf.Bytes() + if len(b) > 0 { + log.Printf("Incomplete upload of %v, %d bytes", oid, len(b)) + largeObjects.SetIncomplete(repo, oid, b) + } + w.WriteHeader(500) + } else { + checkoid := hex.EncodeToString(hash.Sum(nil)) + if checkoid != oid { + log.Fatal(fmt.Sprintf("Incorrect oid after calculation, got %q expected %q", checkoid, oid)) + w.WriteHeader(403) + return + } + + b := buf.Bytes() + largeObjects.Set(repo, oid, b) + w.Header().Set("Upload-Offset", strconv.FormatInt(int64(len(b)), 10)) + w.WriteHeader(204) + } + default: w.WriteHeader(405) } @@ -576,6 +684,13 @@ func testingChunkedTransferEncoding(r *http.Request) bool { return strings.HasPrefix(r.URL.String(), "/test-chunked-transfer-encoding") } +func testingTusUploadInBatchReq(r *http.Request) bool { + return strings.HasPrefix(r.URL.String(), "/test-tus-upload") +} +func testingTusUploadInterruptedInBatchReq(r *http.Request) bool { + return strings.HasPrefix(r.URL.String(), "/test-tus-upload-interrupt") +} + var lfsUrlRE = regexp.MustCompile(`\A/?([^/]+)/info/lfs`) func repoFromLfsUrl(urlpath string) (string, error) { @@ -592,8 +707,9 @@ func repoFromLfsUrl(urlpath string) (string, error) { } type lfsStorage struct { - objects map[string]map[string][]byte - mutex *sync.Mutex + objects map[string]map[string][]byte + incomplete map[string]map[string][]byte + mutex *sync.Mutex } func (s *lfsStorage) Get(repo, oid string) ([]byte, bool) { @@ -640,10 +756,43 @@ func (s *lfsStorage) Delete(repo, oid string) { } } +func (s *lfsStorage) GetIncomplete(repo, oid string) ([]byte, bool) { + s.mutex.Lock() + defer s.mutex.Unlock() + repoObjects, ok := s.incomplete[repo] + if !ok { + return nil, ok + } + + by, ok := repoObjects[oid] + return by, ok +} + +func (s *lfsStorage) SetIncomplete(repo, oid string, by []byte) { + s.mutex.Lock() + defer s.mutex.Unlock() + repoObjects, ok := s.incomplete[repo] + if !ok { + repoObjects = make(map[string][]byte) + s.incomplete[repo] = repoObjects + } + repoObjects[oid] = by +} + +func (s *lfsStorage) DeleteIncomplete(repo, oid string) { + s.mutex.Lock() + defer s.mutex.Unlock() + repoObjects, ok := s.incomplete[repo] + if ok { + delete(repoObjects, oid) + } +} + func newLfsStorage() *lfsStorage { return &lfsStorage{ - objects: make(map[string]map[string][]byte), - mutex: &sync.Mutex{}, + objects: make(map[string]map[string][]byte), + incomplete: make(map[string]map[string][]byte), + mutex: &sync.Mutex{}, } } diff --git a/test/test-resume-tus.sh b/test/test-resume-tus.sh new file mode 100755 index 00000000..37a323e6 --- /dev/null +++ b/test/test-resume-tus.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +. "test/testlib.sh" + +begin_test "tus-upload-uninterrupted" +( + set -e + + # this repo name is the indicator to the server to use tus + reponame="test-tus-upload" + setup_remote_repo "$reponame" + + clone_repo "$reponame" $reponame + + git lfs track "*.dat" 2>&1 | tee track.log + grep "Tracking \*.dat" track.log + + contents="jksgdfljkgsdlkjafg lsjdgf alkjgsd lkfjag sldjkgf alkjsgdflkjagsd kljfg asdjgf kalsd" + contents_oid=$(calc_oid "$contents") + + printf "$contents" > a.dat + git add a.dat + git add .gitattributes + git commit -m "add a.dat" 2>&1 | tee commit.log + GIT_TRACE=1 git push origin master 2>&1 | tee pushtus.log + grep "xfer: tus.io uploading" pushtus.log + + assert_server_object "$reponame" "$contents_oid" + +) +end_test + +begin_test "tus-upload-interrupted-resume" +( + set -e + + # this repo name is the indicator to the server to use tus, AND to + # interrupt the upload part way + reponame="test-tus-upload-interrupt" + setup_remote_repo "$reponame" + + clone_repo "$reponame" $reponame + + git lfs track "*.dat" 2>&1 | tee track.log + grep "Tracking \*.dat" track.log + + # this string announces to server that we want it to abort the download part + # way, but reject the Range: header and fall back on re-downloading instead + contents="234587134187634598o634857619384765b747qcvtuedvoaicwtvseudtvcoqi7280r7qvow4i7r8c46pr9q6v9pri6ioq2r8" + contents_oid=$(calc_oid "$contents") + + printf "$contents" > a.dat + git add a.dat + git add .gitattributes + git commit -m "add a.dat" 2>&1 | tee commit.log + GIT_TRACE=1 git push origin master 2>&1 | tee pushtus_resume.log + # first attempt will start from the beginning + grep "xfer: tus.io uploading" pushtus_resume.log + grep "HTTP: 500" pushtus_resume.log + # that will have failed but retry on 500 will resume it + grep "xfer: tus.io resuming" pushtus_resume.log + grep "HTTP: 204" pushtus_resume.log + + # should have completed in the end + assert_server_object "$reponame" "$contents_oid" + +) +end_test + From 1a8364b0122af21abb4afeec7e02c91499bf881e Mon Sep 17 00:00:00 2001 From: Steve Streeting Date: Tue, 14 Jun 2016 12:55:19 +0100 Subject: [PATCH 3/5] Don't include creds by default in tus call, prompt on 401 instead --- transfer/tus_upload.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/transfer/tus_upload.go b/transfer/tus_upload.go index 8be8c730..2804e07c 100644 --- a/transfer/tus_upload.go +++ b/transfer/tus_upload.go @@ -46,7 +46,7 @@ func (a *tusUploadAdapter) DoTransfer(t *Transfer, cb TransferProgressCallback, return err } req.Header.Set("Tus-Resumable", TusVersion) - res, err := httputil.DoHttpRequest(req, true) + res, err := httputil.DoHttpRequest(req, false) if err != nil { return errutil.NewRetriableError(err) } @@ -127,7 +127,7 @@ func (a *tusUploadAdapter) DoTransfer(t *Transfer, cb TransferProgressCallback, req.Body = ioutil.NopCloser(reader) - res, err = httputil.DoHttpRequest(req, true) + res, err = httputil.DoHttpRequest(req, false) if err != nil { return errutil.NewRetriableError(err) } From db3f17feb7192d41a9a2c0c526515c17b9b698c4 Mon Sep 17 00:00:00 2001 From: Steve Streeting Date: Mon, 4 Jul 2016 12:44:19 +0100 Subject: [PATCH 4/5] PR comments --- test/cmd/lfstest-gitserver.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/test/cmd/lfstest-gitserver.go b/test/cmd/lfstest-gitserver.go index bba5d22e..587ee68e 100644 --- a/test/cmd/lfstest-gitserver.go +++ b/test/cmd/lfstest-gitserver.go @@ -499,23 +499,21 @@ func storageHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) case "HEAD": // tus.io - if len(r.Header.Get("Tus-Resumable")) == 0 { - log.Fatal("Missing Tus-Resumable header in request") + if !validateTusHeaders(r) { w.WriteHeader(400) return } parts := strings.Split(r.URL.Path, "/") oid := parts[len(parts)-1] + var offset int64 if by, ok := largeObjects.GetIncomplete(repo, oid); ok { - w.Header().Set("Upload-Offset", strconv.FormatInt(int64(len(by)), 10)) - } else { - w.Header().Set("Upload-Offset", "0") + offset = int64(len(by)) } + w.Header().Set("Upload-Offset", strconv.FormatInt(offset, 10)) w.WriteHeader(200) case "PATCH": // tus.io - if len(r.Header.Get("Tus-Resumable")) == 0 { - log.Fatal("Missing Tus-Resumable header in request") + if !validateTusHeaders(r) { w.WriteHeader(400) return } @@ -594,6 +592,15 @@ func storageHandler(w http.ResponseWriter, r *http.Request) { } } +func validateTusHeaders(r *http.Request) bool { + if len(r.Header.Get("Tus-Resumable")) == 0 { + log.Fatal("Missing Tus-Resumable header in request") + return false + } + return true + +} + func gitHandler(w http.ResponseWriter, r *http.Request) { defer func() { io.Copy(ioutil.Discard, r.Body) From 1f386c71b733331e3700d1ee62ac386cf101fc4a Mon Sep 17 00:00:00 2001 From: Steve Streeting Date: Tue, 5 Jul 2016 17:21:44 +0100 Subject: [PATCH 5/5] Revert "Add checkout --unstaged flag" --- commands/command_checkout.go | 8 ++------ docs/man/git-lfs-checkout.1.ronn | 7 +------ test/test-checkout.sh | 31 ------------------------------- 3 files changed, 3 insertions(+), 43 deletions(-) diff --git a/commands/command_checkout.go b/commands/command_checkout.go index 395913ab..dfa9e445 100644 --- a/commands/command_checkout.go +++ b/commands/command_checkout.go @@ -20,7 +20,6 @@ var ( Use: "checkout", Run: checkoutCommand, } - checkoutUnstagedArg bool ) func checkoutCommand(cmd *cobra.Command, args []string) { @@ -44,7 +43,6 @@ func checkoutCommand(cmd *cobra.Command, args []string) { } func init() { - checkoutCmd.Flags().BoolVarP(&checkoutUnstagedArg, "unstaged", "u", false, "Do not add files to the index") RootCmd.AddCommand(checkoutCmd) } @@ -209,7 +207,7 @@ func checkoutWithChan(in <-chan *lfs.WrappedPointer) { } } - if cmd == nil && !checkoutUnstagedArg { + if cmd == nil { // Fire up the update-index command cmd = exec.Command("git", "update-index", "-q", "--refresh", "--stdin") updateIdxStdin, err = cmd.StdinPipe() @@ -223,9 +221,7 @@ func checkoutWithChan(in <-chan *lfs.WrappedPointer) { } - if updateIdxStdin != nil { - updateIdxStdin.Write([]byte(cwdfilepath + "\n")) - } + updateIdxStdin.Write([]byte(cwdfilepath + "\n")) } close(repopathchan) diff --git a/docs/man/git-lfs-checkout.1.ronn b/docs/man/git-lfs-checkout.1.ronn index b7f50312..c7dd82f3 100644 --- a/docs/man/git-lfs-checkout.1.ronn +++ b/docs/man/git-lfs-checkout.1.ronn @@ -3,7 +3,7 @@ git-lfs-checkout(1) -- Update working copy with file content if available ## SYNOPSIS -`git lfs checkout` [options] ... +`git lfs checkout` ... ## DESCRIPTION @@ -18,11 +18,6 @@ we have it in the local store. Modified files are never overwritten. Filespecs can be provided as arguments to restrict the files which are updated. -## OPTIONS - -* `--unstaged` `-u`: - Do not add files to the index, keeping them unstaged. - ## EXAMPLES * Checkout all files that are missing or placeholders diff --git a/test/test-checkout.sh b/test/test-checkout.sh index 51daab7c..bcf20a6a 100755 --- a/test/test-checkout.sh +++ b/test/test-checkout.sh @@ -119,34 +119,3 @@ begin_test "checkout: outside git repository" grep "Not in a git repository" checkout.log ) end_test - -begin_test "checkout --unstaged" -( - set -e - - reponame="$(basename "$0" ".sh")-unstaged" - setup_remote_repo "$reponame" - - clone_repo "$reponame" repo-unstaged - - git lfs track "*.dat" 2>&1 | tee track.log - - contents="something something" - - printf "$contents" > file1.dat - git add file1.dat - git add .gitattributes - git commit -m "add files" - - # Remove the working directory - rm -f file1.dat - - echo "checkout should replace all" - git lfs checkout --unstaged - [ "$contents" = "$(cat file1.dat)" ] - - echo "large files should not be staged" - git diff-files - git diff-files | grep file1.dat -) -end_test