├── .github └── workflows │ ├── goreadme.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── bin.go ├── cmd └── gitfs │ ├── .gitignore │ ├── main.go │ ├── main_test.go │ ├── provider.go │ ├── templates.go │ ├── templates │ ├── binary.go.gotmpl │ └── test.go.gotmpl │ └── templates_test.go ├── examples ├── godoc │ └── godoc.go └── templates │ ├── templates.go │ └── tmpl1.gotmpl ├── fsutil ├── diff.go ├── diff_test.go ├── glob.go ├── glob_test.go ├── testdata │ ├── tmpl1.gotmpl │ └── tmpl2.gotmpl ├── tmpl.go ├── tmpl_test.go ├── walk.go └── walk_test.go ├── gitfs.go ├── gitfs_test.go ├── go.mod ├── go.sum └── internal ├── binfs ├── binfs.go ├── binfs_test.go ├── load.go ├── load_test.go └── testdata │ └── testdata.go ├── githubfs ├── getatree.go ├── getcontents.go ├── githubfs.go ├── githubtfs_test.go ├── project.go └── project_test.go ├── glob ├── glob.go └── glob_test.go ├── localfs ├── localfs.go └── localfs_test.go ├── log └── log.go ├── testdata ├── d1 │ └── d11 │ │ └── f111 ├── d2 │ └── f21 └── f01 ├── testfs └── testfs.go └── tree ├── dir.go ├── file.go ├── tree.go └── tree_test.go /.github/workflows/goreadme.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: [master] 4 | push: 5 | branches: [master] 6 | jobs: 7 | goreadme: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Check out repository 11 | uses: actions/checkout@v2 12 | - name: Update readme according to Go doc 13 | uses: posener/goreadme@v1.2.10 14 | with: 15 | recursive: 'true' 16 | badge-codecov: 'true' 17 | badge-godoc: 'true' 18 | github-token: '${{ secrets.GITHUB_TOKEN }}' 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: [master] 4 | push: 5 | branches: [master] 6 | jobs: 7 | test: 8 | strategy: 9 | matrix: 10 | go-version: 11 | - 1.20.x 12 | - 1.21.x 13 | - 1.22.x 14 | platform: 15 | - ubuntu-latest 16 | runs-on: ${{ matrix.platform }} 17 | steps: 18 | - name: Install Go 19 | uses: actions/setup-go@v1 20 | with: 21 | go-version: ${{ matrix.go-version }} 22 | - name: Checkout code 23 | uses: actions/checkout@v2 24 | - name: Test 25 | run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 26 | - name: Report coverage 27 | uses: codecov/codecov-action@v1 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | with: 31 | file: coverage.txt 32 | fail_ci_if_error: true 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.txt 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitfs 2 | 3 | [![codecov](https://codecov.io/gh/posener/gitfs/branch/master/graph/badge.svg)](https://codecov.io/gh/posener/gitfs) 4 | [![GoDoc](https://img.shields.io/badge/pkg.go.dev-doc-blue)](http://pkg.go.dev/github.com/posener/gitfs) 5 | 6 | Package gitfs is a complete solution for static files in Go code. 7 | 8 | When Go code uses non-Go files, they are not packaged into the binary. 9 | The common approach to the problem, as implemented by 10 | [go-bindata](https://github.com/kevinburke/go-bindata) 11 | is to convert all the required static files into Go code, which 12 | eventually compiled into the binary. 13 | 14 | This library takes a different approach, in which the static files are not 15 | required to be "binary-packed", and even no required to be in the same repository 16 | as the Go code. This package enables loading static content from a remote 17 | git repository, or packing it to the binary if desired or loaded 18 | from local path for development process. The transition from remote repository 19 | to binary packed content, to local content is completely smooth. 20 | 21 | *The API is simple and minimalistic*. The `New` method returns a (sub)tree 22 | of a Git repository, represented by the standard `http.FileSystem` interface. 23 | This object enables anything that is possible to do with a regular filesystem, 24 | such as opening a file or listing a directory. 25 | Additionally, the [./fsutil](./fsutil) package provides enhancements over the `http.FileSystem` 26 | object (They can work with any object that implements the interface) such 27 | as loading Go templates in the standard way, walking over the filesystem, 28 | and applying glob patterns on a filesystem. 29 | 30 | Supported features: 31 | 32 | * Loading of specific version/tag/branch. 33 | 34 | * For debug purposes, the files can be loaded from local path instead of the 35 | remote repository. 36 | 37 | * Files are loaded lazily by default or they can be preloaded if required. 38 | 39 | * Files can be packed to the Go binary using a command line tool. 40 | 41 | * This project is using the standard `http.FileSystem` interface. 42 | 43 | * In [./fsutil](./fsutil) there are some general useful tools around the 44 | `http.FileSystem` interace. 45 | 46 | ## Usage 47 | 48 | To create a filesystem using the `New` function, provide the Git 49 | project with the pattern: `github.com//(/)?(@)?`. 50 | If no `path` is specified, the root of the project will be used. 51 | `ref` can be any git branch using `heads/` or any 52 | git tag using `tags/`. If the tag is of Semver format, the `tags/` 53 | prefix is not required. If no `ref` is specified, the default branch will 54 | be used. 55 | 56 | In the following example, the repository `github.com/x/y` at tag v1.2.3 57 | and internal path "static" is loaded: 58 | 59 | ```go 60 | fs, err := gitfs.New(ctx, "github.com/x/y/static@v1.2.3") 61 | ``` 62 | 63 | The variable `fs` implements the `http.FileSystem` interface. 64 | Reading a file from the repository can be done using the `Open` method. 65 | This function accepts a path, relative to the root of the defined 66 | filesystem. 67 | 68 | ```go 69 | f, err := fs.Open("index.html") 70 | ``` 71 | 72 | The `fs` variable can be used in anything that accept the standard interface. 73 | For example, it can be used for serving static content using the standard 74 | library: 75 | 76 | ```go 77 | http.Handle("/", http.FileServer(fs)) 78 | ``` 79 | 80 | ## Private Repositories 81 | 82 | When used with private github repository, the Github API calls should be 83 | instrumented with the appropriate credentials. The credentials can be 84 | passed by providing an HTTP client. 85 | 86 | For example, to use a Github Token from environnement variable `GITHUB_TOKEN`: 87 | 88 | ```go 89 | token := os.Getenv("GITHUB_TOKEN") 90 | client := oauth2.NewClient( 91 | context.Background(), 92 | oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})) 93 | fs, err := gitfs.New(ctx, "github.com/x/y", gitfs.OptClient(client)) 94 | ``` 95 | 96 | ## Development 97 | 98 | For quick development workflows, it is easier and faster to use local static 99 | content and not remote content that was pushed to a remote repository. 100 | This is enabled by the `OptLocal` option. To use this option only in 101 | local development and not in production system, it can be used as follow: 102 | 103 | ```go 104 | local := os.Getenv("LOCAL_DEBUG") 105 | fs, err := gitfs.New(ctx, "github.com/x/y", gitfs.OptLocal(local)) 106 | ``` 107 | 108 | In this example, we stored the value for `OptLocal` in an environment 109 | variable. As a result, when running the program with `LOCAL_DEBUG=.` 110 | local files will be used, while running without it will result in using 111 | the remote files. (the value of the environment variable should point 112 | to any directory within the github project). 113 | 114 | ## Binary Packing 115 | 116 | Using gitfs does not mean that files are required to be remotely fetched. 117 | When binary packing of the files is needed, a command line tool can pack 118 | them for you. 119 | 120 | To get the tool run: `go get github.com/posener/gitfs/cmd/gitfs`. 121 | 122 | Running the tool is by `gitfs `. This generates a `gitfs.go` 123 | file in the current directory that contains all the used filesystems' data. 124 | This will cause all `gitfs.New` calls to automatically use the packed data, 125 | insted of fetching the data on runtime. 126 | 127 | By default, a test will also be generated with the code. This test fails 128 | when the local files are modified without updating the binary content. 129 | 130 | Use binary-packing with `go generate`: To generate all filesystems used 131 | by a project add `//go:generate gitfs ./...` in the root of the project. 132 | To generate only a specific filesystem add `//go:generate gitfs $GOFILE` in 133 | the file it is being used. 134 | 135 | An interesting anecdote is that gitfs command is using itself for generating 136 | its own templates. 137 | 138 | ## Excluding files 139 | 140 | Files exclusion can be done by including only specific files using a glob 141 | pattern with `OptGlob` option, using the Glob options. This will affect 142 | both local loading of files, remote loading and binary packing (may 143 | reduce binary size). For example: 144 | 145 | ```go 146 | fs, err := gitfs.New(ctx, 147 | "github.com/x/y/templates", 148 | gitfs.OptGlob("*.gotmpl", "*/*.gotmpl")) 149 | ``` 150 | 151 | ## Sub Packages 152 | 153 | * [bin](./bin): Package bin is a proxy to the internal/binfs.Register function. 154 | 155 | * [cmd/gitfs](./cmd/gitfs): gitfs command line tool, for generating binary conetent of the used filesystems. 156 | 157 | * [examples/godoc](./examples/godoc): An example locally serves files from github.com/golang/go/doc. 158 | 159 | * [examples/templates](./examples/templates): An example that shows how gitfs helps using template files with Go code smoothly. 160 | 161 | * [fsutil](./fsutil): Package fsutil provides useful utility functions for http.FileSystem. 162 | 163 | ## Examples 164 | 165 | ### Fsutil 166 | 167 | The [./fsutil](./fsutil) package is a collection of useful functions that can work with 168 | any `http.FileSystem` implementation. 169 | For example, here we will use a function that loads go templates from the 170 | filesystem. 171 | 172 | ```golang 173 | ctx := context.Background() 174 | 175 | // Open a git remote repository `posener/gitfs` in path `examples/templates`. 176 | fs, err := New(ctx, "github.com/posener/gitfs/examples/templates") 177 | if err != nil { 178 | log.Fatalf("Failed initialize filesystem: %s", err) 179 | } 180 | 181 | // Use util function that loads all templates according to a glob pattern. 182 | tmpls, err := fsutil.TmplParseGlob(fs, nil, "*.gotmpl") 183 | if err != nil { 184 | log.Fatalf("Failed parsing templates: %s", err) 185 | } 186 | 187 | // Execute the template and write to stdout. 188 | tmpls.ExecuteTemplate(os.Stdout, "tmpl1.gotmpl", "Foo") 189 | ``` 190 | 191 | Output: 192 | 193 | ``` 194 | Hello, Foo 195 | ``` 196 | 197 | ### Open 198 | 199 | With gitfs you can open a remote git repository, and load any file, 200 | including non-go files. 201 | In this example, the README.md file of a remote repository is loaded. 202 | 203 | ```golang 204 | ctx := context.Background() 205 | 206 | // The load path is of the form: github.com//(/)?(@)?. 207 | // `ref` can reference any git tag or branch. If github releases are in Semver format, 208 | // the `tags/` prefix is not needed in the `ref` part. 209 | fs, err := New(ctx, "github.com/kelseyhightower/helloworld@3.0.0") 210 | if err != nil { 211 | log.Fatalf("Failed initialize filesystem: %s", err) 212 | } 213 | 214 | // Open any file in the github repository, using the `Open` function. Both files 215 | // and directory can be opened. The content is not loaded until it is actually being 216 | // read. The content is loaded only once. 217 | f, err := fs.Open("README.md") 218 | if err != nil { 219 | log.Fatalf("Failed opening file: %s", err) 220 | } 221 | 222 | // Copy the content to stdout. 223 | io.Copy(os.Stdout, f) 224 | ``` 225 | 226 | Output: 227 | 228 | ``` 229 | # helloworld 230 | ``` 231 | 232 | --- 233 | Readme created from Go doc with [goreadme](https://github.com/posener/goreadme) 234 | -------------------------------------------------------------------------------- /bin/bin.go: -------------------------------------------------------------------------------- 1 | // Package bin is a proxy to the internal/binfs.Register function. 2 | // 3 | // It is used by the gitfs command line. 4 | package bin 5 | 6 | import "github.com/posener/gitfs/internal/binfs" 7 | 8 | // Register registers binary data of a given project. 9 | func Register(project string, version int, data string) { 10 | binfs.Register(project, version, data) 11 | } 12 | -------------------------------------------------------------------------------- /cmd/gitfs/.gitignore: -------------------------------------------------------------------------------- 1 | testout* -------------------------------------------------------------------------------- /cmd/gitfs/main.go: -------------------------------------------------------------------------------- 1 | // gitfs command line tool, for generating binary conetent of the used filesystems. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "strings" 14 | "text/template" 15 | 16 | "github.com/pkg/errors" 17 | "golang.org/x/tools/go/packages" 18 | 19 | "github.com/posener/gitfs" 20 | "github.com/posener/gitfs/fsutil" 21 | "github.com/posener/gitfs/internal/binfs" 22 | ) 23 | 24 | var ( 25 | out = flag.String("out", "gitfs.go", "Output file") 26 | pkg = flag.String("pkg", "", "Package name for output file (default is the package name of current directory)") 27 | skipTestGen = flag.Bool("skip-test-gen", false, "Skip test generation") 28 | bootstrap = flag.Bool("bootstrap", false, "Bootstrap mode. For package internal usage.") 29 | ) 30 | 31 | // templates are used for the generated files. They 32 | // are loaded with loadTemplate function call. 33 | var templates *template.Template 34 | 35 | func main() { 36 | // Parse flags 37 | flag.Usage = func() { 38 | fmt.Fprintf(flag.CommandLine.Output(), usage) 39 | flag.PrintDefaults() 40 | } 41 | flag.Parse() 42 | if len(flag.Args()) == 0 { 43 | log.Fatal("At least one file pattern should be provided.") 44 | } 45 | 46 | gitfs.SetLogger(log.New(os.Stderr, "[gitfs] ", log.LstdFlags)) 47 | log.Printf("Starting binary packing...") 48 | log.Printf("Encoding version: %d", binfs.EncodeVersion) 49 | loadTemplates() 50 | 51 | // Fix flags. 52 | var err error 53 | *out, err = getOut(*out) 54 | if err != nil { 55 | log.Fatalf("Invalid out flag: %s", err) 56 | } 57 | *pkg, err = getPkg(*pkg, *out) 58 | if err != nil { 59 | log.Fatalf("Invalid: pkg must be provided if output is not a Go package: %s", err) 60 | } 61 | 62 | calls, err := binfs.LoadCalls(flag.Args()...) 63 | if err != nil { 64 | log.Fatalf("Failed loading binaries: %s", err) 65 | } 66 | if len(calls) == 0 { 67 | log.Fatalf("Did not found any calls for gitfs.New") 68 | } 69 | 70 | binaries := binfs.GenerateBinaries(calls, provider) 71 | 72 | // Generate output 73 | createOut(binaries) 74 | createTest(calls) 75 | } 76 | 77 | func createOut(binaries map[string]string) { 78 | f, err := os.Create(*out) 79 | if err != nil { 80 | log.Fatalf("Failed creating file %q: %s", *out, err) 81 | } 82 | defer f.Close() 83 | 84 | err = generate(f, binaries) 85 | if err != nil { 86 | defer os.Remove(*out) 87 | log.Fatalf("Failed generating filesystem: %s", err) 88 | } 89 | defer goimports(*out) 90 | } 91 | 92 | func createTest(calls binfs.Calls) { 93 | if *skipTestGen { 94 | log.Print("Skipping test generation") 95 | return 96 | } 97 | testPath := strings.TrimSuffix(*out, ".go") + "_test.go" 98 | f, err := os.Create(testPath) 99 | if err != nil { 100 | log.Fatalf("Failed creating file %q: %s", testPath, err) 101 | } 102 | defer f.Close() 103 | 104 | testName := strings.Title(strings.TrimSuffix(filepath.Base(*out), ".go")) 105 | 106 | err = generateTest(f, calls, testName) 107 | if err != nil { 108 | defer os.Remove(testPath) 109 | log.Fatalf("Failed generating tests: %s", err) 110 | } 111 | defer goimports(testPath) 112 | } 113 | 114 | func generate(w io.Writer, binaries map[string]string) error { 115 | return templates.ExecuteTemplate(w, "binary.go.gotmpl", struct { 116 | Package string 117 | Binaries map[string]string 118 | Version int 119 | }{ 120 | Package: *pkg, 121 | Binaries: binaries, 122 | Version: binfs.EncodeVersion, 123 | }) 124 | } 125 | 126 | func generateTest(w io.Writer, calls binfs.Calls, testName string) error { 127 | return templates.ExecuteTemplate(w, "test.go.gotmpl", struct { 128 | Package string 129 | Calls binfs.Calls 130 | TestName string 131 | }{ 132 | Package: *pkg, 133 | Calls: calls, 134 | TestName: testName, 135 | }) 136 | } 137 | 138 | // getOut fixes out variable if it points to a directory or a file 139 | // non-existing directory. 140 | func getOut(out string) (string, error) { 141 | if out == "" { 142 | return "gitfs.go", nil 143 | } 144 | st, err := os.Stat(out) 145 | if err != nil { 146 | // File does not exists, make sure it is a file in current directory 147 | // or other existing directory. 148 | if !strings.HasSuffix(out, ".go") { 149 | return "", errors.New("output file should be a go file") 150 | } 151 | dir, _ := filepath.Split(out) 152 | if dir == "" { 153 | // The user chose to create a local file. 154 | return out, nil 155 | } 156 | // The user creates a file in directory `dir`. 157 | st, err := os.Stat(dir) 158 | if err != nil { 159 | return "", errors.Errorf("output directory %q not found: %s", dir, err) 160 | } 161 | if !st.IsDir() { 162 | return "", errors.Errorf("output directory %q is not a directory", dir) 163 | } 164 | return out, nil 165 | } 166 | if st.IsDir() { 167 | // If the given output is a directory, add filename 'gitfs.go'. 168 | out = filepath.Join(out, "gitfs.go") 169 | } 170 | return out, nil 171 | } 172 | 173 | // getPkg fixes the package name according to the given name in the 174 | // command line or the package of the output file. 175 | func getPkg(pkg, out string) (string, error) { 176 | if pkg != "" { 177 | return pkg, nil 178 | } 179 | outDir, _ := filepath.Split(out) 180 | if outDir == "" { 181 | outDir = "." 182 | } 183 | pkgs, err := packages.Load(nil, outDir) 184 | if err != nil { 185 | return "", errors.Errorf("failed loading package in %q: %s", outDir, err) 186 | } 187 | if len(pkgs) == 0 { 188 | return "", errors.Errorf("could not load package in %q", outDir) 189 | } 190 | return pkgs[0].Name, nil 191 | } 192 | 193 | func goimports(path string) { 194 | err := exec.Command("goimports", "-w", path).Run() 195 | if err != nil { 196 | log.Printf("Failed goimports on %s: %s", path, err) 197 | } 198 | } 199 | 200 | //go:generate go run . -bootstrap -out templates.go $GOFILE 201 | func loadTemplates() { 202 | // For bootstrapping purposes, an environment variable must be set 203 | // such that the template themselves will be loaded from local path 204 | // when they are generating their own template. 205 | local := "" 206 | if *bootstrap { 207 | log.Println("Bootstrapping gitfs templates.") 208 | local = "." 209 | } 210 | fs, err := gitfs.New(context.Background(), 211 | "github.com/posener/gitfs/cmd/gitfs/templates", gitfs.OptLocal(local)) 212 | if err != nil { 213 | panic(err) 214 | } 215 | templates = template.Must(fsutil.TmplParseGlob(fs, nil, "*")) 216 | } 217 | 218 | const usage = `gitfs packs filesystems into Go binary for github.com/posener/gitfs library. 219 | 220 | Usage: 221 | 222 | gitfs 223 | 224 | The command will traverses all Go files in the given patterns and 225 | looks for 'gitfs.New' calls. For each of these calls, it downloads the 226 | specified project. All the projects are then saved into a single 227 | go file (default gitfs.go). 228 | When this file is compiled to a Go binary, the projects are automatically 229 | loaded from the packed version instead of remote repository. 230 | 231 | Note: 232 | 233 | The calls for 'gitfs.New' must contain an explicit string represented 234 | project name. With the current implementation, the project can't be 235 | inferred from a variable or a constant. 236 | 237 | 238 | Example: 239 | 240 | To pack all usage of gitfs filesystems in the current project, run from 241 | the root of the project the following command: 242 | 243 | gitfs ./... 244 | 245 | Flags: 246 | 247 | ` 248 | -------------------------------------------------------------------------------- /cmd/gitfs/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "regexp" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestRun(t *testing.T) { 16 | defer clean() 17 | t.Run("Successful run", func(t *testing.T) { 18 | stderr, err := runGo(t, "run", ".", "-out", "testout1.go", "../../examples/templates/...") 19 | assert.NoErrorf(t, err, "Expected success, got error: %s", stderr) 20 | 21 | // Test the output file. 22 | data, err := ioutil.ReadFile("testout1.go") 23 | require.NoError(t, err) 24 | assert.True(t, regexp.MustCompile(`package main`).Match(data)) 25 | 26 | stderr, err = runGo(t, "build", ".") 27 | require.NoErrorf(t, err, "Build failed: %s", stderr) 28 | stderr, err = runGo(t, "test", "./...", "-run", "TestGitFS") 29 | require.NoErrorf(t, err, "Test failed: %s", stderr) 30 | }) 31 | 32 | t.Run("Pattern must be provided", func(t *testing.T) { 33 | _, err := runGo(t, "run", ".", "-out", "testout2.go") 34 | assert.Error(t, err) 35 | 36 | // Test that file was deleted after failure. 37 | _, err = os.Stat("testout2.go") 38 | assert.Error(t, err) 39 | }) 40 | } 41 | 42 | func TestGetOut(t *testing.T) { 43 | t.Parallel() 44 | tests := []struct { 45 | out string 46 | want string 47 | }{ 48 | {out: "", want: "gitfs.go"}, 49 | // A local file stay the same. 50 | {out: "f.go", want: "f.go"}, 51 | // A dir is appended with file. 52 | {out: "/tmp", want: "/tmp/gitfs.go"}, 53 | // Local dir is appended with a file. 54 | {out: ".", want: "gitfs.go"}, 55 | {out: "..", want: "../gitfs.go"}, 56 | // A file in a dir stay the same. 57 | {out: "/tmp/f.go", want: "/tmp/f.go"}, 58 | } 59 | for _, tt := range tests { 60 | t.Run(tt.out, func(t *testing.T) { 61 | got, err := getOut(tt.out) 62 | require.NoError(t, err) 63 | assert.Equal(t, tt.want, got) 64 | }) 65 | } 66 | } 67 | 68 | func TestGetOut_fail(t *testing.T) { 69 | t.Parallel() 70 | tests := []string{ 71 | // Output must be in existing directory. 72 | "nosuchdir/testout.go", 73 | // Output directory must exists. 74 | "nosuchdir/", 75 | } 76 | for _, out := range tests { 77 | t.Run(out, func(t *testing.T) { 78 | _, err := getOut(out) 79 | assert.Error(t, err) 80 | }) 81 | } 82 | } 83 | 84 | func TestGetPkg(t *testing.T) { 85 | t.Parallel() 86 | tests := []struct { 87 | pkg string 88 | out string 89 | want string 90 | }{ 91 | {pkg: "foo", want: "foo"}, 92 | {out: "", want: "main"}, 93 | {out: ".", want: "main"}, 94 | {out: "../../", want: "gitfs"}, 95 | } 96 | for _, tt := range tests { 97 | t.Run(tt.out, func(t *testing.T) { 98 | got, err := getPkg(tt.pkg, tt.out) 99 | require.NoError(t, err) 100 | assert.Equal(t, tt.want, got) 101 | }) 102 | } 103 | } 104 | 105 | func runGo(t *testing.T, args ...string) (stderr string, err error) { 106 | cmd := exec.Command("go", args...) 107 | stderrBuf, err := cmd.StderrPipe() 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | require.NoError(t, cmd.Start()) 112 | stderrBytes, err := ioutil.ReadAll(stderrBuf) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | err = cmd.Wait() 117 | return string(stderrBytes), err 118 | } 119 | 120 | func clean() { 121 | paths, err := filepath.Glob("testout*.go") 122 | if err != nil { 123 | panic(err) 124 | } 125 | for _, path := range paths { 126 | os.Remove(path) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /cmd/gitfs/provider.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/posener/gitfs" 8 | "github.com/posener/gitfs/internal/binfs" 9 | ) 10 | 11 | func provider(c binfs.Config) (http.FileSystem, error) { 12 | return gitfs.New(context.Background(), c.Project, 13 | gitfs.OptPrefetch(true), gitfs.OptLocal("."), gitfs.OptGlob(c.GlobPatterns()...)) 14 | } 15 | -------------------------------------------------------------------------------- /cmd/gitfs/templates.go: -------------------------------------------------------------------------------- 1 | // Code generated by gitfs; DO NOT EDIT 2 | package main 3 | 4 | import "github.com/posener/gitfs/bin" 5 | 6 | func init() { 7 | bin.Register("github.com/posener/gitfs/cmd/gitfs/templates", 1, "H4sIAAAAAAAA/6xU3WobRxTedZ3CDqEtfYGeDg6sijy6LQ6+qOO4FIptEtMbx5jR7tnV1KuZ7cwRtln2oukP9CX6fn2JqJxZSVZJWlLIlbTnnPm+7/yOlz9/lKZZFV6S87rGdPk6SXfSRyemwZAuf03S3WPjQ7r8PUkSufxlN00/n+v2MpA3tr66vFoYS1/HwMepSJIvlr/tpumnWyFT5xp+nj5Od5Lkzcd/Ll+nO58QBlK1U7Wjedu8efTHZALPXIlQo0WvCUuY3kNtqApP4fgMTs8u4Pnxdxei1cWNrhG6Tp0Pf/teCDNvnSfIRSYZ2dhaikwWzhLekRQik7Wh2WKqCjeftC4wySSiy//wTaqwINNIMRJiMoELDNR1oPj3VM8R+h6KGRY3AWimCVga6zZW+3uYaypmyC6ExhW6gcJZtKQilINwY9p1tsbWQDMTgMWDX9ghc7g1NIN9jtxnz36NFqpG10pUC1u8S1BO8NWqAupiBJ3ICrqDg0NY1UId6eKm9m5hy3wksta7H7GgwBGXV4H8oqBOZGs7DD0UWVY3bjpExO+eg7oOvLY1wt71GPYKZytTM5B6ppsmwH7fc5DINnAHILtuHanOVxx9L8cc1HVgKlDfNm56ronQ2zVGZD/Y0EfILXb2jmHvOnK/63kWaWMKazJ+jrZcR/RrBWyLpmjZDupFVjkP12MgYqaBfFNAFkXqxcLmRGplHQO36e2WZNkwI2NA7xkrdlud4m1e0B0TPCAMrrOWODHG5jSUUqMR45gqQnx5CNY0A3RG6kSTbqpcnmjTYAmN0yVP2Gow17198tMBPAnyn3TofQSOVYlj+79Efs8vcqnk6IMqH/bn/YWXpqo2uoclVsemqvJVRkMl3l9Hi75yfs5SKj6M94FwDsyyUvIWu/oGDkFGOrmxHbFt4OabFMlLlhi9L+Nw56OnULIcKTdqnnvvPKt54L7VAeauNJXBEnRF6KHRgdY93lxRBecN6oDgcbChemW5FAevLCsvN7L7Ec94Lz4bIB6O8/KvD3Kb//3KTo2VYrhoxhrK4448rPemxXtTY+OOH7FCg6v9nhqrXmBtAqHP46a3W5cF2KB+QB+Ms9D34+EGMVTfy5HYWvpepEnydwAAAP//AB8qHREHAAA=") 8 | 9 | } 10 | -------------------------------------------------------------------------------- /cmd/gitfs/templates/binary.go.gotmpl: -------------------------------------------------------------------------------- 1 | // Code generated by gitfs; DO NOT EDIT 2 | package {{.Package}} 3 | 4 | import "github.com/posener/gitfs/bin" 5 | 6 | func init() { 7 | {{ range $project, $bin := .Binaries -}} 8 | bin.Register("{{ $project }}", {{ $.Version }}, "{{ $bin }}") 9 | {{ end }} 10 | } 11 | -------------------------------------------------------------------------------- /cmd/gitfs/templates/test.go.gotmpl: -------------------------------------------------------------------------------- 1 | // Code generated by gitfs; DO NOT EDIT 2 | package {{.Package}} 3 | 4 | import ( 5 | "testing" 6 | "context" 7 | 8 | "github.com/posener/gitfs" 9 | "github.com/posener/gitfs/fsutil" 10 | ) 11 | 12 | // Test{{ .TestName }} checks that packed binary matches the local conent. 13 | // To skip generating this test run gitfs with -skip-test-gen flag. 14 | func Test{{ .TestName }}(t *testing.T) { 15 | ctx := context.Background() 16 | projects := []struct{ 17 | project string 18 | glob []string 19 | }{ 20 | {{ range $_, $config := .Calls -}} 21 | { 22 | project: "{{ $config.Project }}", 23 | {{ if .GlobPatterns -}} 24 | glob: []string{ 25 | {{ range $glob, $_ := .GlobPatterns -}} 26 | "{{ $glob }}", 27 | {{ end -}} 28 | }, 29 | {{ end }} 30 | }, 31 | {{ end -}} 32 | } 33 | for _, tt := range projects { 34 | t.Run(tt.project, func(t *testing.T) { 35 | binary, err := gitfs.New(ctx, tt.project, gitfs.OptGlob(tt.glob...)) 36 | if err != nil { 37 | t.Fatalf("Failed loading binary project %q: %s", tt.project, err) 38 | } 39 | local, err := gitfs.New(ctx, tt.project, gitfs.OptLocal("."), gitfs.OptGlob(tt.glob...)) 40 | if err != nil { 41 | t.Fatalf("Failed loading local project %q: %s", tt.project, err) 42 | } 43 | diff, err := fsutil.Diff(local, binary) 44 | if err != nil { 45 | t.Fatalf("Failed performing filesystem diff: %s", err) 46 | } 47 | diff.A = "local" 48 | diff.B = "binary" 49 | 50 | if d := diff.String(); d != "" { 51 | t.Errorf("Filesystem was modified after last binary generated. Please regenerae.\nDiff:\n%s",d) 52 | } 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /cmd/gitfs/templates_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by gitfs; DO NOT EDIT 2 | package main 3 | 4 | import ( 5 | "context" 6 | "testing" 7 | 8 | "github.com/posener/gitfs" 9 | "github.com/posener/gitfs/fsutil" 10 | ) 11 | 12 | // TestTemplates checks that packed binary matches the local conent. 13 | // To skip generating this test run gitfs with -skip-test-gen flag. 14 | func TestTemplates(t *testing.T) { 15 | ctx := context.Background() 16 | projects := []struct { 17 | project string 18 | glob []string 19 | }{ 20 | { 21 | project: "github.com/posener/gitfs/cmd/gitfs/templates", 22 | }, 23 | } 24 | for _, tt := range projects { 25 | t.Run(tt.project, func(t *testing.T) { 26 | binary, err := gitfs.New(ctx, tt.project, gitfs.OptGlob(tt.glob...)) 27 | if err != nil { 28 | t.Fatalf("Failed loading binary project %q: %s", tt.project, err) 29 | } 30 | local, err := gitfs.New(ctx, tt.project, gitfs.OptLocal("."), gitfs.OptGlob(tt.glob...)) 31 | if err != nil { 32 | t.Fatalf("Failed loading local project %q: %s", tt.project, err) 33 | } 34 | diff, err := fsutil.Diff(local, binary) 35 | if err != nil { 36 | t.Fatalf("Failed performing filesystem diff: %s", err) 37 | } 38 | diff.A = "local" 39 | diff.B = "binary" 40 | 41 | if d := diff.String(); d != "" { 42 | t.Errorf("Filesystem was modified after last binary generated. Please regenerae.\nDiff:\n%s", d) 43 | } 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/godoc/godoc.go: -------------------------------------------------------------------------------- 1 | // An example locally serves files from github.com/golang/go/doc. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/posener/gitfs" 10 | ) 11 | 12 | func main() { 13 | ctx := context.Background() 14 | fs, err := gitfs.New(ctx, "github.com/golang/go/doc") 15 | if err != nil { 16 | log.Fatalf("Failed initializing git filesystem: %s.", err) 17 | } 18 | http.Handle("/", http.RedirectHandler("/doc/", http.StatusMovedPermanently)) 19 | http.Handle("/doc/", http.StripPrefix("/doc/", http.FileServer(fs))) 20 | log.Printf("Listening on :8080") 21 | log.Fatal(http.ListenAndServe(":8080", nil)) 22 | } 23 | -------------------------------------------------------------------------------- /examples/templates/templates.go: -------------------------------------------------------------------------------- 1 | // An example that shows how gitfs helps using template files with Go code smoothly. 2 | package main 3 | 4 | import ( 5 | "context" 6 | "log" 7 | "os" 8 | 9 | "github.com/posener/gitfs" 10 | "github.com/posener/gitfs/fsutil" 11 | ) 12 | 13 | // Add debug mode environment variable. When running with `LOCAL_DEBUG=.`, the 14 | // local git repository will be used instead of the remote github. 15 | var localDebug = os.Getenv("LOCAL_DEBUG") 16 | 17 | func main() { 18 | ctx := context.Background() 19 | // Open repository 'github.com/posener/gitfs' at path 20 | // 'examples/templates' with the local option from 21 | // environment variable. 22 | fs, err := gitfs.New(ctx, 23 | "github.com/posener/gitfs/examples/templates", 24 | gitfs.OptLocal(localDebug)) 25 | if err != nil { 26 | log.Fatalf("Failed initializing git filesystem: %s.", err) 27 | } 28 | // Parse templates from the loaded filesystem using a glob 29 | // pattern. 30 | tmpls, err := fsutil.TmplParseGlob(fs, nil, "*.gotmpl") 31 | if err != nil { 32 | log.Fatalf("Failed parsing templates.") 33 | } 34 | // Execute a template according to its file name. 35 | tmpls.ExecuteTemplate(os.Stdout, "tmpl1.gotmpl", "Foo") 36 | } 37 | -------------------------------------------------------------------------------- /examples/templates/tmpl1.gotmpl: -------------------------------------------------------------------------------- 1 | Hello, {{.}} 2 | -------------------------------------------------------------------------------- /fsutil/diff.go: -------------------------------------------------------------------------------- 1 | package fsutil 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "sort" 8 | "strings" 9 | "text/template" 10 | 11 | "github.com/pkg/errors" 12 | "github.com/posener/diff" 13 | ) 14 | 15 | const ( 16 | msgOnlyInA = "only in {{.A}}" 17 | msgOnlyInB = "only in {{.B}}" 18 | msgAFileBDir = "on {{.A}} is file, on {{.B}} directory" 19 | msgADirBFile = "on {{.A}} is directory, on {{.B}} file" 20 | msgContentDiff = "content diff (-{{.A}}, +{{.B}}):" 21 | ) 22 | 23 | // FileSystemDiff lists all differences between two filesystems. 24 | type FileSystemDiff struct { 25 | Diffs []PathDiff 26 | // FileSystem names. 27 | A, B string 28 | } 29 | 30 | // PathDiff is a diff between two filesystems at a single path. 31 | type PathDiff struct { 32 | Path string 33 | Diff string 34 | DiffInfo string 35 | } 36 | 37 | func (d *FileSystemDiff) template(tmpl string) string { 38 | out := bytes.NewBuffer(nil) 39 | err := template.Must(template.New("title").Parse(tmpl)).Execute(out, d) 40 | if err != nil { 41 | panic(err) 42 | } 43 | return out.String() 44 | } 45 | 46 | // String returns pretty representation of a filesystem diff. 47 | func (d *FileSystemDiff) String() string { 48 | if len(d.Diffs) == 0 { 49 | return "" 50 | } 51 | // Concatenate all differences. 52 | out := strings.Builder{} 53 | out.WriteString(d.template("Diff between {{.A}} and {{.B}}:\n")) 54 | for _, diff := range d.Diffs { 55 | out.WriteString("[" + diff.Path + "]: " + d.template(diff.Diff) + "\n") 56 | if diff.DiffInfo != "" { 57 | out.WriteString(diff.DiffInfo + "\n") 58 | } 59 | } 60 | return out.String() 61 | } 62 | 63 | // Diff returns the difference in filesystem structure and file content 64 | // between two filesystems. If the implementation of the filesystem is 65 | // different but the structure and content are equal, the function will 66 | // consider the object as equal. 67 | // For equal filesystems, an empty slice will be returned. 68 | // The returned differences are ordered by file path. 69 | func Diff(a, b http.FileSystem) (*FileSystemDiff, error) { 70 | aFiles, err := lsR(a) 71 | if err != nil { 72 | return nil, errors.Errorf("walking filesystem a: %s", err) 73 | } 74 | bFiles, err := lsR(b) 75 | if err != nil { 76 | return nil, errors.Errorf("walking filesystem b: %s", err) 77 | } 78 | 79 | d := &FileSystemDiff{A: "a", B: "b"} 80 | // Compare two slices of ordered file names. Always compare first element 81 | // in each slice and pop the elements from the slice accordingly. 82 | for len(aFiles) > 0 || len(bFiles) > 0 { 83 | switch { 84 | case len(bFiles) == 0 || (len(aFiles) > 0 && aFiles[0] < bFiles[0]): 85 | // File exists only in a. 86 | path := aFiles[0] 87 | d.Diffs = append(d.Diffs, PathDiff{Path: path, Diff: msgOnlyInA}) 88 | aFiles = aFiles[1:] 89 | case len(aFiles) == 0 || (len(bFiles) > 0 && bFiles[0] < aFiles[0]): 90 | // File exists only in b. 91 | path := bFiles[0] 92 | d.Diffs = append(d.Diffs, PathDiff{Path: path, Diff: msgOnlyInB}) 93 | bFiles = bFiles[1:] 94 | default: 95 | // File exists both in a and in b. 96 | path := aFiles[0] 97 | diff, err := contentDiff(a, b, path) 98 | if err != nil { 99 | return nil, err 100 | } 101 | if diff != nil { 102 | d.Diffs = append(d.Diffs, *diff) 103 | } 104 | aFiles = aFiles[1:] 105 | bFiles = bFiles[1:] 106 | } 107 | } 108 | return d, nil 109 | } 110 | 111 | // lsR is ls -r. Sorted by name. 112 | func lsR(fs http.FileSystem) ([]string, error) { 113 | w := Walk(fs, "") 114 | var paths []string 115 | for w.Step() { 116 | paths = append(paths, w.Path()) 117 | } 118 | if err := w.Err(); err != nil { 119 | return nil, err 120 | } 121 | sort.Strings(paths) 122 | return paths, nil 123 | } 124 | 125 | func contentDiff(a, b http.FileSystem, path string) (*PathDiff, error) { 126 | aF, err := a.Open(path) 127 | if err != nil { 128 | return nil, errors.Wrapf(err, "open %s in filesystem a", path) 129 | } 130 | defer aF.Close() 131 | 132 | bF, err := b.Open(path) 133 | if err != nil { 134 | return nil, errors.Wrapf(err, "open %s in filesystem b", path) 135 | } 136 | defer bF.Close() 137 | 138 | aSt, err := aF.Stat() 139 | if err != nil { 140 | return nil, errors.Wrapf(err, "stat %s in filesystem a", path) 141 | } 142 | 143 | bSt, err := bF.Stat() 144 | if err != nil { 145 | return nil, errors.Wrapf(err, "stat %s in filesystem b", path) 146 | } 147 | 148 | if aSt.IsDir() || bSt.IsDir() { 149 | if !aSt.IsDir() { 150 | return &PathDiff{Path: path, Diff: msgAFileBDir}, nil 151 | } 152 | if !bSt.IsDir() { 153 | return &PathDiff{Path: path, Diff: msgADirBFile}, nil 154 | } 155 | return nil, nil 156 | } 157 | 158 | aData, err := ioutil.ReadAll(aF) 159 | if err != nil { 160 | return nil, errors.Wrapf(err, "reading %s from filesystem a", path) 161 | } 162 | 163 | bData, err := ioutil.ReadAll(bF) 164 | if err != nil { 165 | return nil, errors.Wrapf(err, "reading %s from filesystem b", path) 166 | } 167 | 168 | if string(aData) == string(bData) { 169 | return nil, nil 170 | } 171 | d := diff.Format(string(aData), string(bData), diff.OptSuppressCommon()) 172 | if d != "" { 173 | return &PathDiff{ 174 | Path: path, 175 | Diff: msgContentDiff, 176 | DiffInfo: strings.TrimRight(d, "\n"), 177 | }, nil 178 | } 179 | return nil, nil 180 | } 181 | -------------------------------------------------------------------------------- /fsutil/diff_test.go: -------------------------------------------------------------------------------- 1 | package fsutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/posener/gitfs/internal/tree" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestDiff(t *testing.T) { 12 | t.Parallel() 13 | 14 | a := make(tree.Tree) 15 | a.AddFileContent("content-diff", []byte("1\n2\n")) 16 | a.AddFileContent("content-equal", []byte("1\n2\n")) 17 | a.AddFileContent("common-dir/only-in-a", []byte("")) 18 | a.AddFileContent("file-in-a-dir-in-b", []byte("")) 19 | a.AddDir("dir-in-a-file-in-b") 20 | 21 | b := make(tree.Tree) 22 | b.AddFileContent("content-diff", []byte("1\n3\n")) 23 | b.AddFileContent("content-equal", []byte("1\n2\n")) 24 | b.AddFileContent("common-dir/only-in-b", []byte("")) 25 | b.AddDir("file-in-a-dir-in-b") 26 | b.AddFileContent("dir-in-a-file-in-b", []byte("")) 27 | 28 | want := `Diff between a and b: 29 | [common-dir/only-in-a]: only in a 30 | [common-dir/only-in-b]: only in b 31 | [content-diff]: content diff (-a, +b): 32 | 2-2 33 | 2+3 34 | [dir-in-a-file-in-b]: on a is directory, on b file 35 | [file-in-a-dir-in-b]: on a is file, on b directory 36 | ` 37 | got, err := Diff(a, b) 38 | require.NoError(t, err) 39 | assert.Equal(t, want, got.String()) 40 | } 41 | 42 | func TestDiffEmpty(t *testing.T) { 43 | t.Parallel() 44 | 45 | a := make(tree.Tree) 46 | a.AddFileContent("foo", []byte("")) 47 | 48 | b := make(tree.Tree) 49 | 50 | got, err := Diff(a, b) 51 | require.NoError(t, err) 52 | assert.ElementsMatch(t, []PathDiff{{Path: "foo", Diff: msgOnlyInA}}, got.Diffs) 53 | 54 | // Mirror test 55 | got, err = Diff(b, a) 56 | require.NoError(t, err) 57 | assert.ElementsMatch(t, []PathDiff{{Path: "foo", Diff: msgOnlyInB}}, got.Diffs) 58 | } 59 | -------------------------------------------------------------------------------- /fsutil/glob.go: -------------------------------------------------------------------------------- 1 | package fsutil 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path/filepath" 7 | 8 | globutil "github.com/posener/gitfs/internal/glob" 9 | ) 10 | 11 | // Glob return a filesystem that contain only files that match any of the provided 12 | // patterns. If no patterns are provided, the original filesystem will be returned. 13 | // An error will be returned if one of the patterns is invalid. 14 | func Glob(fs http.FileSystem, patterns ...string) (http.FileSystem, error) { 15 | if len(patterns) == 0 { 16 | return fs, nil 17 | } 18 | p, err := globutil.New(patterns...) 19 | if err != nil { 20 | return nil, err 21 | } 22 | return &glob{FileSystem: fs, patterns: p}, nil 23 | } 24 | 25 | // glob is an object that play the role of an http.FileSystem and an http.File. 26 | // it wraps an existing underlying http.FileSystem, but applies glob pattern 27 | // matching on its files. 28 | type glob struct { 29 | http.FileSystem 30 | http.File 31 | root string 32 | patterns globutil.Patterns 33 | } 34 | 35 | // Open a file, relative to root. If the file exists in the filesystem 36 | // but does not match any of the patterns an os.ErrNotExist will be 37 | // returned. If name is a directory, but it does not match the prefix 38 | // of any of the patterns, and os.ErrNotExist will be returned. 39 | func (g *glob) Open(name string) (http.File, error) { 40 | path := filepath.Join(g.root, name) 41 | f, err := g.FileSystem.Open(path) 42 | if err != nil { 43 | return nil, err 44 | } 45 | info, err := f.Stat() 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | // Regular file, match name. 51 | if !g.patterns.Match(path, info.IsDir()) { 52 | return nil, os.ErrNotExist 53 | } 54 | return &glob{ 55 | FileSystem: g.FileSystem, 56 | File: f, 57 | root: path, 58 | patterns: g.patterns, 59 | }, nil 60 | } 61 | 62 | // Readdir returns a list of files that match the patterns. 63 | func (g *glob) Readdir(count int) ([]os.FileInfo, error) { 64 | files, err := g.File.Readdir(count) 65 | if err != nil { 66 | return nil, err 67 | } 68 | ret := make([]os.FileInfo, 0, len(files)) 69 | for _, file := range files { 70 | path := filepath.Join(g.root, file.Name()) 71 | if g.patterns.Match(path, file.IsDir()) { 72 | ret = append(ret, file) 73 | } 74 | } 75 | return ret, nil 76 | } 77 | -------------------------------------------------------------------------------- /fsutil/glob_test.go: -------------------------------------------------------------------------------- 1 | package fsutil 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | // pwd is the filesystem on which all tests run. 13 | var pwd = http.Dir(".") 14 | 15 | func TestGlobOpen(t *testing.T) { 16 | t.Parallel() 17 | tests := []struct { 18 | patterns []string 19 | matches []string 20 | notMatches []string 21 | }{ 22 | { 23 | patterns: []string{""}, 24 | notMatches: []string{"testdata", "./testdata"}, 25 | }, 26 | { 27 | patterns: []string{"testdata"}, 28 | matches: []string{"testdata", "./testdata", "testdata/", "./testdata/"}, 29 | }, 30 | { 31 | patterns: []string{"", "testdata"}, 32 | matches: []string{"testdata"}, 33 | }, 34 | { 35 | patterns: []string{"testdata", ""}, 36 | matches: []string{"testdata"}, 37 | }, 38 | { 39 | patterns: []string{"*/*1.gotmpl"}, 40 | matches: []string{"testdata/tmpl1.gotmpl", "./testdata/tmpl1.gotmpl", "./testdata/tmpl1.gotmpl/"}, 41 | notMatches: []string{"testdata/tmpl2.gotmpl", "./testdata/tmpl2.gotmpl", "./testdata/tmpl2.gotmpl/"}, 42 | }, 43 | } 44 | for _, tt := range tests { 45 | t.Run(strings.Join(tt.patterns, ":"), func(t *testing.T) { 46 | g, err := Glob(pwd, tt.patterns...) 47 | assert.NoError(t, err) 48 | for _, match := range tt.matches { 49 | t.Run("matches:"+match, func(t *testing.T) { 50 | _, err = g.Open(match) 51 | assert.NoError(t, err) 52 | }) 53 | } 54 | for _, notMatch := range tt.notMatches { 55 | t.Run("not matches:"+notMatch, func(t *testing.T) { 56 | _, err = g.Open(notMatch) 57 | assert.EqualError(t, err, "file does not exist") 58 | }) 59 | } 60 | }) 61 | } 62 | } 63 | 64 | func TestGlobListDir(t *testing.T) { 65 | t.Parallel() 66 | tests := []struct { 67 | patterns []string 68 | open string 69 | foundFiles []string 70 | }{ 71 | { 72 | patterns: []string{"testdata"}, 73 | open: "testdata", 74 | }, 75 | { 76 | patterns: []string{"", "testdata"}, 77 | open: "testdata", 78 | }, 79 | { 80 | patterns: []string{"testdata", ""}, 81 | open: "testdata", 82 | }, 83 | { 84 | patterns: []string{"*/*1.gotmpl"}, 85 | open: "testdata", 86 | foundFiles: []string{"tmpl1.gotmpl"}, 87 | }, 88 | { 89 | patterns: []string{"*/*.gotmpl"}, 90 | open: "testdata", 91 | foundFiles: []string{"tmpl1.gotmpl", "tmpl2.gotmpl"}, 92 | }, 93 | { 94 | // Extra part of path, there is no directory that fit this. 95 | patterns: []string{"*/*.gotmpl/*"}, 96 | open: "testdata", 97 | }, 98 | { 99 | // No slash, only directory is available, but not the files in it. 100 | patterns: []string{"*"}, 101 | open: "testdata", 102 | }, 103 | { 104 | // Matching a two components glob should match only directories. 105 | patterns: []string{"*/*"}, 106 | open: ".", 107 | foundFiles: []string{"testdata"}, 108 | }, 109 | } 110 | for _, tt := range tests { 111 | t.Run(strings.Join(tt.patterns, ":"), func(t *testing.T) { 112 | g, err := Glob(pwd, tt.patterns...) 113 | assert.NoError(t, err) 114 | dir, err := g.Open(tt.open) 115 | require.NoError(t, err) 116 | files, err := dir.Readdir(0) 117 | require.NoError(t, err) 118 | // Copy file names 119 | names := make([]string, 0, len(files)) 120 | for _, file := range files { 121 | names = append(names, file.Name()) 122 | } 123 | assert.ElementsMatch(t, names, tt.foundFiles) 124 | }) 125 | } 126 | } 127 | 128 | func TestGlobOpenDir_failure(t *testing.T) { 129 | t.Parallel() 130 | tests := []struct { 131 | patterns []string 132 | open string 133 | }{ 134 | { 135 | patterns: []string{""}, 136 | open: "testdata", 137 | }, 138 | { 139 | patterns: []string{"*"}, 140 | open: "testdata1", 141 | }, 142 | } 143 | for _, tt := range tests { 144 | t.Run(strings.Join(tt.patterns, ":"), func(t *testing.T) { 145 | g, err := Glob(pwd, tt.patterns...) 146 | assert.NoError(t, err) 147 | _, err = g.Open(tt.open) 148 | require.Error(t, err) 149 | }) 150 | } 151 | } 152 | 153 | func TestGlobReadDir_failure(t *testing.T) { 154 | t.Parallel() 155 | g, err := Glob(pwd, "*/*") 156 | assert.NoError(t, err) 157 | f, err := g.Open("testdata/tmpl1.gotmpl") 158 | require.NoError(t, err) 159 | // This is a file, so Readdir should fail 160 | _, err = f.Readdir(0) 161 | assert.Error(t, err) 162 | } 163 | 164 | func TestGlob_badPattern(t *testing.T) { 165 | t.Parallel() 166 | _, err := Glob(pwd, "[") // Missing closing bracket. 167 | assert.Error(t, err) 168 | } 169 | 170 | func TestGlob_noPattern(t *testing.T) { 171 | t.Parallel() 172 | g, err := Glob(pwd) 173 | require.NoError(t, err) 174 | assert.Equal(t, pwd, g) 175 | } 176 | -------------------------------------------------------------------------------- /fsutil/testdata/tmpl1.gotmpl: -------------------------------------------------------------------------------- 1 | hello, {{.}} -------------------------------------------------------------------------------- /fsutil/testdata/tmpl2.gotmpl: -------------------------------------------------------------------------------- 1 | hi, {{.}} -------------------------------------------------------------------------------- /fsutil/tmpl.go: -------------------------------------------------------------------------------- 1 | package fsutil 2 | 3 | import ( 4 | "bytes" 5 | htmltmpl "html/template" 6 | "net/http" 7 | "path/filepath" 8 | "strings" 9 | txttmpl "text/template" 10 | 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // TmplParse parses templates from the given filesystem according to the 15 | // given paths. If tmpl is not nil, the templates will be added to it. 16 | // paths must contain at least one path. All paths must exist in the 17 | // given filesystem. 18 | func TmplParse(fs http.FileSystem, tmpl *txttmpl.Template, paths ...string) (*txttmpl.Template, error) { 19 | t := tmplParser{Template: tmpl} 20 | err := parseFiles(fs, t.parse, paths...) 21 | return t.Template, err 22 | } 23 | 24 | // TmplParseGlob parses templates from the given filesystem according to 25 | // the provided glob pattern. If tmpl is not nil, the templates will be 26 | // added to it. 27 | func TmplParseGlob(fs http.FileSystem, tmpl *txttmpl.Template, pattern string) (*txttmpl.Template, error) { 28 | t := tmplParser{Template: tmpl} 29 | err := parseGlob(fs, t.parse, pattern) 30 | return t.Template, err 31 | } 32 | 33 | // TmplParseHTML parses HTML templates from the given filesystem according 34 | // to the given paths. If tmpl is not nil, the templates will be added to 35 | // it. paths must contain at least one path. All paths must exist in the 36 | // given filesystem. 37 | func TmplParseHTML(fs http.FileSystem, tmpl *htmltmpl.Template, paths ...string) (*htmltmpl.Template, error) { 38 | t := tmplParserHTML{Template: tmpl} 39 | err := parseFiles(fs, t.parse, paths...) 40 | return t.Template, err 41 | } 42 | 43 | // TmplParseGlobHTML parses HTML templates from the given filesystem 44 | // according to the provided glob pattern. If tmpl is not nil, the 45 | // templates will be added to it. 46 | func TmplParseGlobHTML(fs http.FileSystem, tmpl *htmltmpl.Template, pattern string) (*htmltmpl.Template, error) { 47 | t := tmplParserHTML{Template: tmpl} 48 | err := parseGlob(fs, t.parse, pattern) 49 | return t.Template, err 50 | } 51 | 52 | type tmplParser struct { 53 | *txttmpl.Template 54 | } 55 | 56 | func (t *tmplParser) parse(name, content string) error { 57 | var err error 58 | if t.Template == nil { 59 | t.Template = txttmpl.New(name) 60 | } else { 61 | t.Template = t.New(name) 62 | } 63 | t.Template, err = t.Parse(content) 64 | return err 65 | } 66 | 67 | type tmplParserHTML struct { 68 | *htmltmpl.Template 69 | } 70 | 71 | func (t *tmplParserHTML) parse(name, content string) error { 72 | var err error 73 | if t.Template == nil { 74 | t.Template = htmltmpl.New(name) 75 | } else { 76 | t.Template = t.New(name) 77 | } 78 | t.Template, err = t.Parse(content) 79 | return err 80 | } 81 | 82 | func parseFiles(fs http.FileSystem, parse func(name string, content string) error, paths ...string) error { 83 | if len(paths) == 0 { 84 | return errors.New("no paths provided") 85 | } 86 | buf := bytes.NewBuffer(nil) 87 | for _, path := range paths { 88 | f, err := fs.Open(strings.Trim(path, "/")) 89 | if err != nil { 90 | return errors.Wrapf(err, "opening template %s", path) 91 | } 92 | name := filepath.Base(path) 93 | buf.Reset() 94 | buf.ReadFrom(f) 95 | err = parse(name, buf.String()) 96 | if err != nil { 97 | return errors.Wrapf(err, "parsing template %s", path) 98 | } 99 | } 100 | return nil 101 | } 102 | 103 | func parseGlob(fs http.FileSystem, parse func(name string, content string) error, pattern string) error { 104 | buf := bytes.NewBuffer(nil) 105 | walker := Walk(fs, "") 106 | for walker.Step() { 107 | matched, err := filepath.Match(pattern, walker.Path()) 108 | if err != nil { 109 | return err 110 | } 111 | if !matched { 112 | continue 113 | } 114 | 115 | f, err := fs.Open(walker.Path()) 116 | if err != nil { 117 | return errors.Wrapf(err, "opening template %s", walker.Path()) 118 | } 119 | st, err := f.Stat() 120 | if err != nil { 121 | return errors.Wrapf(err, "stat %s", walker.Path()) 122 | } 123 | if st.IsDir() { 124 | continue 125 | } 126 | 127 | buf.Reset() 128 | buf.ReadFrom(f) 129 | err = parse(walker.Stat().Name(), buf.String()) 130 | if err != nil { 131 | return errors.Wrapf(err, "parsing template %s", walker.Path()) 132 | } 133 | } 134 | if err := walker.Err(); err != nil { 135 | return errors.Wrap(err, "failed walking filesystem") 136 | } 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /fsutil/tmpl_test.go: -------------------------------------------------------------------------------- 1 | package fsutil 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestTmplParse(t *testing.T) { 13 | t.Parallel() 14 | fs := http.Dir(".") 15 | buf := bytes.NewBuffer(nil) 16 | 17 | tmpl, err := TmplParse(fs, nil, "testdata/tmpl1.gotmpl", "testdata/tmpl2.gotmpl") 18 | require.NoError(t, err) 19 | 20 | buf.Reset() 21 | require.NoError(t, tmpl.ExecuteTemplate(buf, "tmpl1.gotmpl", "foo")) 22 | assert.Equal(t, "hello, foo", buf.String()) 23 | 24 | buf.Reset() 25 | require.NoError(t, tmpl.ExecuteTemplate(buf, "tmpl2.gotmpl", "foo")) 26 | assert.Equal(t, "hi, foo", buf.String()) 27 | } 28 | 29 | func TestTmplParse_noSuchFile(t *testing.T) { 30 | t.Parallel() 31 | fs := http.Dir(".") 32 | _, err := TmplParse(fs, nil, "testdata/tmpl1.gotmpl", "testdata/nosuchfile") 33 | assert.Error(t, err) 34 | _, err = TmplParse(fs, nil, "testdata/nosuchfile", "testdata/tmpl1.gotmpl") 35 | assert.Error(t, err) 36 | } 37 | 38 | func TestTmplParse_emptyFileNames(t *testing.T) { 39 | t.Parallel() 40 | fs := http.Dir(".") 41 | _, err := TmplParse(fs, nil) 42 | assert.Error(t, err) 43 | } 44 | 45 | func TestTmplParseGlob(t *testing.T) { 46 | t.Parallel() 47 | buf := bytes.NewBuffer(nil) 48 | fs := http.Dir(".") 49 | 50 | // Match all files in the directory. 51 | tmpl, err := TmplParseGlob(fs, nil, "testdata/*.gotmpl") 52 | require.NoError(t, err) 53 | 54 | buf.Reset() 55 | require.NoError(t, tmpl.ExecuteTemplate(buf, "tmpl1.gotmpl", "foo")) 56 | assert.Equal(t, "hello, foo", buf.String()) 57 | 58 | buf.Reset() 59 | require.NoError(t, tmpl.ExecuteTemplate(buf, "tmpl2.gotmpl", "foo")) 60 | assert.Equal(t, "hi, foo", buf.String()) 61 | 62 | // Match only one file. 63 | tmpl, err = TmplParseGlob(fs, nil, "testdata/tmpl1.*") 64 | require.NoError(t, err) 65 | 66 | buf.Reset() 67 | require.NoError(t, tmpl.ExecuteTemplate(buf, "tmpl1.gotmpl", "foo")) 68 | assert.Equal(t, "hello, foo", buf.String()) 69 | 70 | buf.Reset() 71 | assert.Error(t, tmpl.ExecuteTemplate(buf, "tmpl2.gotmpl", "foo")) 72 | } 73 | 74 | func TestTmplParseHTML(t *testing.T) { 75 | t.Parallel() 76 | fs := http.Dir(".") 77 | tmpl, err := TmplParseHTML(fs, nil, "testdata/tmpl1.gotmpl", "testdata/tmpl2.gotmpl") 78 | require.NoError(t, err) 79 | buf := bytes.NewBuffer(nil) 80 | require.NoError(t, tmpl.ExecuteTemplate(buf, "tmpl1.gotmpl", "foo")) 81 | assert.Equal(t, "hello, foo", buf.String()) 82 | 83 | buf.Reset() 84 | require.NoError(t, tmpl.ExecuteTemplate(buf, "tmpl2.gotmpl", "foo")) 85 | assert.Equal(t, "hi, foo", buf.String()) 86 | } 87 | 88 | func TestTmplParseGlobHTML(t *testing.T) { 89 | t.Parallel() 90 | buf := bytes.NewBuffer(nil) 91 | fs := http.Dir(".") 92 | 93 | // Match all files in the directory. 94 | tmpl, err := TmplParseGlobHTML(fs, nil, "testdata/*.gotmpl") 95 | require.NoError(t, err) 96 | 97 | buf.Reset() 98 | require.NoError(t, tmpl.ExecuteTemplate(buf, "tmpl1.gotmpl", "foo")) 99 | assert.Equal(t, "hello, foo", buf.String()) 100 | 101 | buf.Reset() 102 | require.NoError(t, tmpl.ExecuteTemplate(buf, "tmpl2.gotmpl", "foo")) 103 | assert.Equal(t, "hi, foo", buf.String()) 104 | 105 | // Match only one file. 106 | tmpl, err = TmplParseGlobHTML(fs, nil, "testdata/tmpl1.*") 107 | require.NoError(t, err) 108 | 109 | buf.Reset() 110 | require.NoError(t, tmpl.ExecuteTemplate(buf, "tmpl1.gotmpl", "foo")) 111 | assert.Equal(t, "hello, foo", buf.String()) 112 | 113 | buf.Reset() 114 | assert.Error(t, tmpl.ExecuteTemplate(buf, "tmpl2.gotmpl", "foo")) 115 | } 116 | 117 | func TestTmplParseHTML_noSuchFile(t *testing.T) { 118 | t.Parallel() 119 | fs := http.Dir(".") 120 | _, err := TmplParseHTML(fs, nil, "testdata/tmpl1.gotmpl", "testdata/nosuchfile") 121 | assert.Error(t, err) 122 | _, err = TmplParseHTML(fs, nil, "testdata/nosuchfile", "testdata/tmpl1.gotmpl") 123 | assert.Error(t, err) 124 | } 125 | 126 | func TestTmplParseHTML_emptyFileNames(t *testing.T) { 127 | t.Parallel() 128 | fs := http.Dir(".") 129 | _, err := TmplParseHTML(fs, nil) 130 | assert.Error(t, err) 131 | } 132 | -------------------------------------------------------------------------------- /fsutil/walk.go: -------------------------------------------------------------------------------- 1 | // Package fsutil provides useful utility functions for http.FileSystem. 2 | package fsutil 3 | 4 | import ( 5 | "net/http" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/kr/fs" 10 | ) 11 | 12 | // Walk returns a fs.Walker over http.FileSystem, which enables 13 | // walking over all files in the filesystem. 14 | // 15 | // See https://godoc.org/github.com/kr/fs#Walker for more details. 16 | func Walk(hfs http.FileSystem, root string) *fs.Walker { 17 | return fs.WalkFS(root, fileSystem{hfs}) 18 | } 19 | 20 | // FileSystem implements fs.FileSystem over http.FileSystem. 21 | // 22 | // See https://godoc.org/github.com/kr/fs#FileSystem for more details. 23 | type fileSystem struct { 24 | http.FileSystem 25 | } 26 | 27 | func (fs fileSystem) ReadDir(dirname string) ([]os.FileInfo, error) { 28 | f, err := fs.Open(dirname) 29 | if err != nil { 30 | return nil, err 31 | } 32 | return f.Readdir(-1) 33 | } 34 | 35 | func (fs fileSystem) Lstat(name string) (os.FileInfo, error) { 36 | f, err := fs.Open(name) 37 | if err != nil { 38 | return nil, err 39 | } 40 | return f.Stat() 41 | } 42 | 43 | func (fileSystem) Join(elem ...string) string { 44 | return filepath.Join(elem...) 45 | } 46 | -------------------------------------------------------------------------------- /fsutil/walk_test.go: -------------------------------------------------------------------------------- 1 | package fsutil 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestWalk(t *testing.T) { 11 | t.Parallel() 12 | 13 | var got []string 14 | for walker := Walk(http.Dir("../internal"), "testdata"); walker.Step(); { 15 | got = append(got, walker.Path()) 16 | } 17 | want := []string{ 18 | "testdata", 19 | "testdata/f01", 20 | "testdata/d2", 21 | "testdata/d2/f21", 22 | "testdata/d1", 23 | "testdata/d1/d11", 24 | "testdata/d1/d11/f111", 25 | } 26 | assert.ElementsMatch(t, want, got) 27 | } 28 | -------------------------------------------------------------------------------- /gitfs.go: -------------------------------------------------------------------------------- 1 | // Package gitfs is a complete solution for static files in Go code. 2 | // 3 | // When Go code uses non-Go files, they are not packaged into the binary. 4 | // The common approach to the problem, as implemented by 5 | // (go-bindata) https://github.com/kevinburke/go-bindata 6 | // is to convert all the required static files into Go code, which 7 | // eventually compiled into the binary. 8 | // 9 | // This library takes a different approach, in which the static files are not 10 | // required to be "binary-packed", and even no required to be in the same repository 11 | // as the Go code. This package enables loading static content from a remote 12 | // git repository, or packing it to the binary if desired or loaded 13 | // from local path for development process. The transition from remote repository 14 | // to binary packed content, to local content is completely smooth. 15 | // 16 | // *The API is simple and minimalistic*. The `New` method returns a (sub)tree 17 | // of a Git repository, represented by the standard `http.FileSystem` interface. 18 | // This object enables anything that is possible to do with a regular filesystem, 19 | // such as opening a file or listing a directory. 20 | // Additionally, the ./fsutil package provides enhancements over the `http.FileSystem` 21 | // object (They can work with any object that implements the interface) such 22 | // as loading Go templates in the standard way, walking over the filesystem, 23 | // and applying glob patterns on a filesystem. 24 | // 25 | // Supported features: 26 | // 27 | // * Loading of specific version/tag/branch. 28 | // 29 | // * For debug purposes, the files can be loaded from local path instead of the 30 | // remote repository. 31 | // 32 | // * Files are loaded lazily by default or they can be preloaded if required. 33 | // 34 | // * Files can be packed to the Go binary using a command line tool. 35 | // 36 | // * This project is using the standard `http.FileSystem` interface. 37 | // 38 | // * In ./fsutil there are some general useful tools around the 39 | // `http.FileSystem` interace. 40 | // 41 | // Usage 42 | // 43 | // To create a filesystem using the `New` function, provide the Git 44 | // project with the pattern: `github.com//(/)?(@)?`. 45 | // If no `path` is specified, the root of the project will be used. 46 | // `ref` can be any git branch using `heads/` or any 47 | // git tag using `tags/`. If the tag is of Semver format, the `tags/` 48 | // prefix is not required. If no `ref` is specified, the default branch will 49 | // be used. 50 | // 51 | // In the following example, the repository `github.com/x/y` at tag v1.2.3 52 | // and internal path "static" is loaded: 53 | // 54 | // fs, err := gitfs.New(ctx, "github.com/x/y/static@v1.2.3") 55 | // 56 | // The variable `fs` implements the `http.FileSystem` interface. 57 | // Reading a file from the repository can be done using the `Open` method. 58 | // This function accepts a path, relative to the root of the defined 59 | // filesystem. 60 | // 61 | // f, err := fs.Open("index.html") 62 | // 63 | // The `fs` variable can be used in anything that accept the standard interface. 64 | // For example, it can be used for serving static content using the standard 65 | // library: 66 | // 67 | // http.Handle("/", http.FileServer(fs)) 68 | // 69 | // Private Repositories 70 | // 71 | // When used with private github repository, the Github API calls should be 72 | // instrumented with the appropriate credentials. The credentials can be 73 | // passed by providing an HTTP client. 74 | // 75 | // For example, to use a Github Token from environnement variable `GITHUB_TOKEN`: 76 | // 77 | // token := os.Getenv("GITHUB_TOKEN") 78 | // client := oauth2.NewClient( 79 | // context.Background(), 80 | // oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})) 81 | // fs, err := gitfs.New(ctx, "github.com/x/y", gitfs.OptClient(client)) 82 | // 83 | // Development 84 | // 85 | // For quick development workflows, it is easier and faster to use local static 86 | // content and not remote content that was pushed to a remote repository. 87 | // This is enabled by the `OptLocal` option. To use this option only in 88 | // local development and not in production system, it can be used as follow: 89 | // 90 | // local := os.Getenv("LOCAL_DEBUG") 91 | // fs, err := gitfs.New(ctx, "github.com/x/y", gitfs.OptLocal(local)) 92 | // 93 | // In this example, we stored the value for `OptLocal` in an environment 94 | // variable. As a result, when running the program with `LOCAL_DEBUG=.` 95 | // local files will be used, while running without it will result in using 96 | // the remote files. (the value of the environment variable should point 97 | // to any directory within the github project). 98 | // 99 | // Binary Packing 100 | // 101 | // Using gitfs does not mean that files are required to be remotely fetched. 102 | // When binary packing of the files is needed, a command line tool can pack 103 | // them for you. 104 | // 105 | // To get the tool run: `go get github.com/posener/gitfs/cmd/gitfs`. 106 | // 107 | // Running the tool is by `gitfs `. This generates a `gitfs.go` 108 | // file in the current directory that contains all the used filesystems' data. 109 | // This will cause all `gitfs.New` calls to automatically use the packed data, 110 | // insted of fetching the data on runtime. 111 | // 112 | // By default, a test will also be generated with the code. This test fails 113 | // when the local files are modified without updating the binary content. 114 | // 115 | // Use binary-packing with `go generate`: To generate all filesystems used 116 | // by a project add `//go:generate gitfs ./...` in the root of the project. 117 | // To generate only a specific filesystem add `//go:generate gitfs $GOFILE` in 118 | // the file it is being used. 119 | // 120 | // An interesting anecdote is that gitfs command is using itself for generating 121 | // its own templates. 122 | // 123 | // Excluding files 124 | // 125 | // Files exclusion can be done by including only specific files using a glob 126 | // pattern with `OptGlob` option, using the Glob options. This will affect 127 | // both local loading of files, remote loading and binary packing (may 128 | // reduce binary size). For example: 129 | // 130 | // fs, err := gitfs.New(ctx, 131 | // "github.com/x/y/templates", 132 | // gitfs.OptGlob("*.gotmpl", "*/*.gotmpl")) 133 | package gitfs 134 | 135 | import ( 136 | "context" 137 | "net/http" 138 | 139 | "github.com/pkg/errors" 140 | "github.com/posener/gitfs/fsutil" 141 | "github.com/posener/gitfs/internal/binfs" 142 | "github.com/posener/gitfs/internal/githubfs" 143 | "github.com/posener/gitfs/internal/localfs" 144 | "github.com/posener/gitfs/internal/log" 145 | ) 146 | 147 | // OptClient sets up an HTTP client to perform request to the remote repository. 148 | // This client can be used for authorization credentials. 149 | func OptClient(client *http.Client) option { 150 | return func(c *config) { 151 | c.client = client 152 | } 153 | } 154 | 155 | // OptLocal result in looking for local git repository before accessing remote 156 | // repository. The given path should be contained in a git repository which 157 | // has a remote URL that matches the requested project. 158 | func OptLocal(path string) option { 159 | return func(c *config) { 160 | c.localPath = path 161 | } 162 | } 163 | 164 | // OptPrefetch sets prefetching all files in the filesystem when it is initially 165 | // loaded. 166 | func OptPrefetch(prefetch bool) option { 167 | return func(c *config) { 168 | c.prefetch = prefetch 169 | } 170 | } 171 | 172 | // OptGlob define glob patterns for which only matching files and directories 173 | // will be included in the filesystem. 174 | func OptGlob(patterns ...string) option { 175 | return func(c *config) { 176 | c.patterns = patterns 177 | } 178 | } 179 | 180 | // New returns a new git filesystem for the given project. 181 | // 182 | // Github: 183 | // If the given project is a github project (of the form github.com//(@)?(#)? ), 184 | // the returned filesystem will be fetching files from the given project. 185 | // ref is optional and can be any github ref: 186 | // * `heads/` for a branch. 187 | // * `tags/` for releases or git tags. 188 | // * `` for Semver compatible releases (e.g. v1.2.3). 189 | // If no ref is set, the default branch will be used. 190 | func New(ctx context.Context, project string, opts ...option) (http.FileSystem, error) { 191 | var c config 192 | for _, opt := range opts { 193 | opt(&c) 194 | } 195 | 196 | switch { 197 | case c.localPath != "": 198 | log.Printf("FileSystem %q from local directory", project) 199 | fs, err := localfs.New(project, c.localPath) 200 | if err != nil { 201 | return nil, err 202 | } 203 | return fsutil.Glob(fs, c.patterns...) 204 | case binfs.Match(project): 205 | log.Printf("FileSystem %q from binary", project) 206 | return binfs.Get(project), nil 207 | case githubfs.Match(project): 208 | log.Printf("FileSystem %q from remote Github repository", project) 209 | return githubfs.New(ctx, c.client, project, c.prefetch, c.patterns) 210 | default: 211 | return nil, errors.Errorf("project %q not supported", project) 212 | } 213 | } 214 | 215 | // WithContext applies context to an http.File if it implements the 216 | // contexter interface. 217 | // 218 | // Usage example: 219 | // 220 | // f, err := fs.Open("file") 221 | // // Handle err... 222 | // f = gitfs.WithContext(f, ctx) 223 | // _, err = f.Read(...) 224 | func WithContext(f http.File, ctx context.Context) http.File { 225 | fCtx, ok := f.(contexter) 226 | if !ok { 227 | return f 228 | } 229 | return fCtx.WithContext(ctx) 230 | } 231 | 232 | // SetLogger sets informative logging for gitfs. If nil, no logging 233 | // will be done. 234 | func SetLogger(logger log.Logger) { 235 | log.Log = logger 236 | } 237 | 238 | type config struct { 239 | client *http.Client 240 | localPath string 241 | prefetch bool 242 | patterns []string 243 | } 244 | 245 | type option func(*config) 246 | 247 | type contexter interface { 248 | WithContext(ctx context.Context) http.File 249 | } 250 | -------------------------------------------------------------------------------- /gitfs_test.go: -------------------------------------------------------------------------------- 1 | package gitfs 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log" 7 | "net/http" 8 | "os" 9 | "testing" 10 | 11 | "github.com/posener/gitfs/fsutil" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "golang.org/x/oauth2" 15 | ) 16 | 17 | // With gitfs you can open a remote git repository, and load any file, 18 | // including non-go files. 19 | // In this example, the README.md file of a remote repository is loaded. 20 | func Example_open() { 21 | ctx := context.Background() 22 | 23 | // The load path is of the form: github.com//(/)?(@)?. 24 | // `ref` can reference any git tag or branch. If github releases are in Semver format, 25 | // the `tags/` prefix is not needed in the `ref` part. 26 | fs, err := New(ctx, "github.com/kelseyhightower/helloworld@3.0.0") 27 | if err != nil { 28 | log.Fatalf("Failed initialize filesystem: %s", err) 29 | } 30 | 31 | // Open any file in the github repository, using the `Open` function. Both files 32 | // and directory can be opened. The content is not loaded until it is actually being 33 | // read. The content is loaded only once. 34 | f, err := fs.Open("README.md") 35 | if err != nil { 36 | log.Fatalf("Failed opening file: %s", err) 37 | } 38 | 39 | // Copy the content to stdout. 40 | io.Copy(os.Stdout, f) 41 | 42 | // Output: # helloworld 43 | } 44 | 45 | // The ./fsutil package is a collection of useful functions that can work with 46 | // any `http.FileSystem` implementation. 47 | // For example, here we will use a function that loads go templates from the 48 | // filesystem. 49 | func Example_fsutil() { 50 | ctx := context.Background() 51 | 52 | // Open a git remote repository `posener/gitfs` in path `examples/templates`. 53 | fs, err := New(ctx, "github.com/posener/gitfs/examples/templates") 54 | if err != nil { 55 | log.Fatalf("Failed initialize filesystem: %s", err) 56 | } 57 | 58 | // Use util function that loads all templates according to a glob pattern. 59 | tmpls, err := fsutil.TmplParseGlob(fs, nil, "*.gotmpl") 60 | if err != nil { 61 | log.Fatalf("Failed parsing templates: %s", err) 62 | } 63 | 64 | // Execute the template and write to stdout. 65 | tmpls.ExecuteTemplate(os.Stdout, "tmpl1.gotmpl", "Foo") 66 | 67 | // Output: Hello, Foo 68 | } 69 | 70 | // Tests not supported repository pattern. 71 | func TestNew_notSupported(t *testing.T) { 72 | t.Parallel() 73 | ctx := context.Background() 74 | _, err := New(ctx, "git.com/nosuchusername/nosuchproject") 75 | require.Error(t, err) 76 | } 77 | 78 | // Tests loading of local repository. 79 | func TestNew_local(t *testing.T) { 80 | t.Parallel() 81 | ctx := context.Background() 82 | _, err := New(ctx, "github.com/posener/gitfs", OptLocal(".")) 83 | require.NoError(t, err) 84 | } 85 | 86 | func TestWithContext(t *testing.T) { 87 | t.Parallel() 88 | fs, err := New(context.Background(), "github.com/posener/gitfs") 89 | require.NoError(t, err) 90 | f, err := fs.Open("README.md") 91 | require.NoError(t, err) 92 | ctx, cancel := context.WithCancel(context.Background()) 93 | cancel() 94 | f = WithContext(f, ctx) 95 | _, err = f.Read(make([]byte, 10)) 96 | assert.EqualError(t, err, "failed getting blob: context canceled") 97 | } 98 | 99 | func init() { 100 | // Set Github access token in default client if available 101 | // from environment variables. 102 | token := os.Getenv("GITHUB_TOKEN") 103 | if token != "" { 104 | http.DefaultClient = oauth2.NewClient( 105 | context.Background(), 106 | oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/posener/gitfs 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/go-git/go-billy/v5 v5.5.0 7 | github.com/go-git/go-git/v5 v5.12.0 8 | github.com/google/go-github v17.0.0+incompatible 9 | github.com/google/go-querystring v1.0.0 // indirect 10 | github.com/kr/fs v0.1.0 11 | github.com/pkg/errors v0.9.1 12 | github.com/posener/diff v0.0.1 13 | github.com/stretchr/testify v1.9.0 14 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 15 | golang.org/x/tools v0.13.0 16 | google.golang.org/appengine v1.6.1 // indirect 17 | ) 18 | 19 | replace rsc.io/diff => github.com/posener/diff v0.0.0-20190808172948-eff7f6d9b194 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 3 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 4 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 5 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 6 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 7 | github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= 8 | github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 10 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 12 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 13 | github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 14 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 15 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 16 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 17 | github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= 18 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 19 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 20 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 21 | github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= 22 | github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= 23 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= 27 | github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= 28 | github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= 29 | github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= 30 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 31 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 32 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 33 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 34 | github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= 35 | github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= 36 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 37 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 38 | github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= 39 | github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= 40 | github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= 41 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 42 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 43 | github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= 44 | github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= 45 | github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 46 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= 47 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 48 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 49 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 50 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 51 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 52 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 53 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 54 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 55 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 56 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 57 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 58 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 59 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 60 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 61 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 62 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 63 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 64 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 65 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 66 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 67 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 68 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 69 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 70 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 71 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 72 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 73 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 74 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 75 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 76 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 77 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= 78 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 79 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 80 | github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 81 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 82 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 83 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 84 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 85 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 86 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 87 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 88 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 89 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 90 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 91 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 92 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 93 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 94 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 95 | github.com/mmcloughlin/avo v0.5.0/go.mod h1:ChHFdoV7ql95Wi7vuq2YT1bwCJqiWdZrQ1im3VujLYM= 96 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 97 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 98 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 99 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 100 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 101 | github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= 102 | github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= 103 | github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= 104 | github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= 105 | github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= 106 | github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0= 107 | github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= 108 | github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= 109 | github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= 110 | github.com/onsi/ginkgo/v2 v2.8.1/go.mod h1:N1/NbDngAFcSLdyZ+/aYTYGSlq9qMCS/cNKGJjy+csc= 111 | github.com/onsi/ginkgo/v2 v2.9.0/go.mod h1:4xkjoL/tZv4SMWeww56BU5kAt19mVB47gTWxmrTcxyk= 112 | github.com/onsi/ginkgo/v2 v2.9.1/go.mod h1:FEcmzVcCHl+4o9bQZVab+4dC9+j+91t2FHSzmGAPfuo= 113 | github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= 114 | github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= 115 | github.com/onsi/ginkgo/v2 v2.9.7/go.mod h1:cxrmXWykAwTwhQsJOPfdIDiJ+l2RYq7U8hFU+M/1uw0= 116 | github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU= 117 | github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM= 118 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 119 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 120 | github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= 121 | github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= 122 | github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= 123 | github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc= 124 | github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM= 125 | github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 126 | github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 127 | github.com/onsi/gomega v1.26.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 128 | github.com/onsi/gomega v1.27.1/go.mod h1:aHX5xOykVYzWOV4WqQy0sy8BQptgukenXpCXfadcIAw= 129 | github.com/onsi/gomega v1.27.3/go.mod h1:5vG284IBtfDAmDyrK+eGyZmUgUlmi+Wngqo557cZ6Gw= 130 | github.com/onsi/gomega v1.27.4/go.mod h1:riYq/GJKh8hhoM01HN6Vmuy93AarCXCBGpvFDK3q3fQ= 131 | github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 132 | github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4= 133 | github.com/onsi/gomega v1.27.8/go.mod h1:2J8vzI/s+2shY9XHRApDkdgPo1TKT7P2u6fXeJKFnNQ= 134 | github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= 135 | github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= 136 | github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= 137 | github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 138 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 139 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 140 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 141 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 142 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 143 | github.com/posener/diff v0.0.1 h1:rjxZ4l6g5DixF+LKqFFEZpTXY2kitoiGfph/sVRjqoM= 144 | github.com/posener/diff v0.0.1/go.mod h1:hZraNYAlXkt6AyFW523B2inR/zd+gmL9WNJB45sKFzQ= 145 | github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= 146 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 147 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 148 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 149 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 150 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 151 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 152 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 153 | github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= 154 | github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= 155 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 156 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 157 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 158 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 159 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 160 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 161 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 162 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 163 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 164 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 165 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 166 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 167 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 168 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 169 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 170 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 171 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 172 | github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 173 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 174 | golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 175 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 176 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 177 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 178 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 179 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 180 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 181 | golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 182 | golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= 183 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 184 | golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= 185 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 186 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 187 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 188 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 189 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 190 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 191 | golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 192 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 193 | golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 194 | golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 195 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 196 | golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 197 | golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 198 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= 199 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 200 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 201 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 202 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 203 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 204 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 205 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 206 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 207 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 208 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 209 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 210 | golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 211 | golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 212 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 213 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 214 | golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 215 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 216 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 217 | golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 218 | golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 219 | golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 220 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 221 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 222 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 223 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 224 | golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= 225 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 226 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 227 | golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= 228 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 229 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= 230 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 231 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 232 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 233 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 234 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 235 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 236 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 237 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 238 | golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 239 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 240 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 241 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 242 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 243 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 244 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 245 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 246 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 247 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 248 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 249 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 250 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 251 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 252 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 253 | golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 254 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 255 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 256 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 257 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 258 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 259 | golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 260 | golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 261 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 262 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 263 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 264 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 265 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 266 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 267 | golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 268 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 269 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 270 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 271 | golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 272 | golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 273 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 274 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 275 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 276 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 277 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 278 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 279 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 280 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 281 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 282 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 283 | golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 284 | golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 285 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 286 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 287 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 288 | golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= 289 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 290 | golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= 291 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 292 | golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= 293 | golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 294 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 295 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 296 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 297 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 298 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 299 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 300 | golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 301 | golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 302 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 303 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 304 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 305 | golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 306 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 307 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 308 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 309 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 310 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 311 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 312 | golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 313 | golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 314 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 315 | golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 316 | golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 317 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 318 | golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= 319 | golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= 320 | golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= 321 | golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= 322 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 323 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 324 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 325 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 326 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 327 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 328 | google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I= 329 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= 330 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 331 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 332 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 333 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 334 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 335 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 336 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 337 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 338 | google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= 339 | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 340 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 341 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 342 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 343 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 344 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 345 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 346 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 347 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 348 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 349 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 350 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 351 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 352 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 353 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 354 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 355 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 356 | -------------------------------------------------------------------------------- /internal/binfs/binfs.go: -------------------------------------------------------------------------------- 1 | // Package binfs is filesystem over registered binary data. 2 | // 3 | // This pacakge is used by ./cmd/gitfs to generate files that 4 | // contain static content of a filesystem. 5 | package binfs 6 | 7 | import ( 8 | "bytes" 9 | "compress/gzip" 10 | "encoding/base64" 11 | "encoding/gob" 12 | "fmt" 13 | "io" 14 | "io/ioutil" 15 | "log" 16 | "net/http" 17 | 18 | "github.com/pkg/errors" 19 | "github.com/posener/gitfs/fsutil" 20 | "github.com/posener/gitfs/internal/tree" 21 | ) 22 | 23 | // EncodeVersion is the current encoding version. 24 | const EncodeVersion = 1 25 | 26 | // data maps registered projects (through `Register()` call) 27 | // to the corresponding filesystem that they represent. 28 | var data map[string]http.FileSystem 29 | 30 | // fsStorage stores all filesystem structure and all file contents. 31 | type fsStorage struct { 32 | // Files maps all file paths from root of the filesystem to 33 | // their contents. 34 | Files map[string][]byte 35 | // Dirs is the set of paths of directories in the filesystem. 36 | Dirs map[string]bool 37 | } 38 | 39 | func init() { 40 | data = make(map[string]http.FileSystem) 41 | gob.Register(fsStorage{}) 42 | } 43 | 44 | // Register a filesystem under the project name. 45 | // It panics if anything goes wrong. 46 | func Register(project string, version int, encoded string) { 47 | if data[project] != nil { 48 | panic(fmt.Sprintf("Project %s registered multiple times", project)) 49 | } 50 | var ( 51 | fs http.FileSystem 52 | err error 53 | ) 54 | switch version { 55 | case 1: 56 | fs, err = decodeV1(encoded) 57 | default: 58 | panic(fmt.Sprintf(`Registered filesystem is from future version %d. 59 | The current gitfs suports versions up to %d. 60 | Please update github.com/posener/gitfs.`, version, EncodeVersion)) 61 | } 62 | if err != nil { 63 | panic(fmt.Sprintf("Failed decoding project %q: %s", project, err)) 64 | } 65 | data[project] = fs 66 | } 67 | 68 | // Match returns wether project exists in registered binaries. 69 | // The matching is done also over the project `ref`. 70 | func Match(project string) bool { 71 | _, ok := data[project] 72 | return ok 73 | } 74 | 75 | // Get returns filesystem of a registered project. 76 | func Get(project string) http.FileSystem { 77 | return data[project] 78 | } 79 | 80 | // encode converts a filesystem to an encoded string. All filesystem structure 81 | // and file content is stored. 82 | // 83 | // Note: modifying this function should probably increase EncodeVersion const, 84 | // and should probably add a new `decode` function for the new version. 85 | func encode(fs http.FileSystem) (string, error) { 86 | // storage is an object that contains all filesystem information. 87 | storage := newFSStorage() 88 | 89 | // Walk the provided filesystem, and add all its content to storage. 90 | walker := fsutil.Walk(fs, "") 91 | for walker.Step() { 92 | path := walker.Path() 93 | if path == "" { 94 | continue 95 | } 96 | if walker.Stat().IsDir() { 97 | storage.Dirs[path] = true 98 | } else { 99 | b, err := readFile(fs, path) 100 | if err != nil { 101 | return "", err 102 | } 103 | storage.Files[path] = b 104 | } 105 | log.Printf("Encoded path: %s", path) 106 | } 107 | if err := walker.Err(); err != nil { 108 | return "", errors.Wrap(err, "walking filesystem") 109 | } 110 | 111 | // Encode the storage object into a string. 112 | // storage object -> GOB -> gzip -> base64. 113 | var buf bytes.Buffer 114 | w := gzip.NewWriter(&buf) 115 | err := gob.NewEncoder(w).Encode(storage) 116 | if err != nil { 117 | return "", errors.Wrap(err, "encoding gob") 118 | } 119 | err = w.Close() 120 | if err != nil { 121 | return "", errors.Wrap(err, "close gzip") 122 | } 123 | s := base64.StdEncoding.EncodeToString(buf.Bytes()) 124 | log.Printf("Encoded size: %d", len(s)) 125 | return s, err 126 | } 127 | 128 | // decodeV1 returns a filesystem from data that was encoded in V1. 129 | func decodeV1(data string) (tree.Tree, error) { 130 | var storage fsStorage 131 | b, err := base64.StdEncoding.DecodeString(data) 132 | if err != nil { 133 | return nil, errors.Wrap(err, "decoding base64") 134 | } 135 | var r io.ReadCloser 136 | r, err = gzip.NewReader(bytes.NewReader(b)) 137 | if err != nil { 138 | // Fallback to non-zipped version. 139 | log.Printf( 140 | "Decoding gzip: %s. Falling back to non-gzip loading.", 141 | err) 142 | r = ioutil.NopCloser(bytes.NewReader(b)) 143 | } 144 | defer r.Close() 145 | err = gob.NewDecoder(r).Decode(&storage) 146 | if err != nil { 147 | return nil, errors.Wrap(err, "decoding gob") 148 | } 149 | t := make(tree.Tree) 150 | for dir := range storage.Dirs { 151 | t.AddDir(dir) 152 | } 153 | for path, content := range storage.Files { 154 | t.AddFileContent(path, content) 155 | } 156 | return t, err 157 | } 158 | 159 | // readFile is a utility function that reads content of the file 160 | // denoted by path from the provided filesystem. 161 | func readFile(fs http.FileSystem, path string) ([]byte, error) { 162 | f, err := fs.Open(path) 163 | if err != nil { 164 | return nil, errors.Wrapf(err, "opening file %s", path) 165 | } 166 | defer f.Close() 167 | b, err := ioutil.ReadAll(f) 168 | if err != nil { 169 | return nil, errors.Wrapf(err, "reading file content %s", path) 170 | } 171 | return b, nil 172 | } 173 | 174 | func newFSStorage() fsStorage { 175 | return fsStorage{ 176 | Files: make(map[string][]byte), 177 | Dirs: make(map[string]bool), 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /internal/binfs/binfs_test.go: -------------------------------------------------------------------------------- 1 | package binfs 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRegister_illegalVersion(t *testing.T) { 10 | t.Parallel() 11 | assert.Panics(t, func() { Register("github.com/x/y", EncodeVersion+1, "") }) 12 | } 13 | -------------------------------------------------------------------------------- /internal/binfs/load.go: -------------------------------------------------------------------------------- 1 | package binfs 2 | 3 | import ( 4 | "go/ast" 5 | "go/token" 6 | "log" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | "golang.org/x/tools/go/packages" 12 | ) 13 | 14 | // Calls is a map of project to load configuration. 15 | type Calls map[string]*Config 16 | 17 | // Config is a configuration for generating a filesystem. 18 | type Config struct { 19 | Project string 20 | // globPatterns is a union of all globPatterns that found in all calls 21 | // for this project. 22 | globPatterns []string 23 | // a helper field, used to indicate if there was a project import without 24 | // a usage of pattern (this means that we should not have patterns applied 25 | // in the binary creation). 26 | noPatterns bool 27 | } 28 | 29 | // GlobPatterns that should be used for this project. 30 | func (c *Config) GlobPatterns() []string { 31 | if c.noPatterns { 32 | return nil 33 | } 34 | return c.globPatterns 35 | } 36 | 37 | // fsProviderFn is a function that given a project name it returns 38 | // its filesystem. 39 | type fsProviderFn func(c Config) (http.FileSystem, error) 40 | 41 | // LoadCalls load all calls to gitfs.New in the files according to the defined patterns. 42 | func LoadCalls(patterns ...string) (Calls, error) { 43 | pkgs, err := packages.Load(&packages.Config{Mode: packages.LoadAllSyntax}, patterns...) 44 | if err != nil { 45 | return nil, errors.Wrap(err, "loading packages") 46 | } 47 | 48 | // Check if any file was loaded. 49 | totalFiles := 0 50 | for _, pkg := range pkgs { 51 | totalFiles += len(pkg.Syntax) 52 | } 53 | if totalFiles == 0 { 54 | return nil, errors.New("no packages were loaded") 55 | } 56 | 57 | // Find all projects 58 | c := make(Calls) 59 | for _, pkg := range pkgs { 60 | for _, file := range pkg.Syntax { 61 | c.lookupAST(file, pkg.Fset) 62 | } 63 | } 64 | return c, nil 65 | } 66 | 67 | // GenerateBinaries generate binary representation to all given calls. 68 | // The returned map maps project name that is used in any of the files that matched 69 | // any of the pattern to its binary encoded content. 70 | func GenerateBinaries(c Calls, provider fsProviderFn) map[string]string { 71 | // Load all binaries 72 | binaries := make(map[string]string) 73 | for project, config := range c { 74 | binaries[project] = loadBinary(provider, *config) 75 | } 76 | return binaries 77 | } 78 | 79 | // lookupAST inspects a single AST and looks for `gitfs.New` calls. 80 | // If a call was found, it saves the project this call was called for 81 | // and options it was called with. 82 | func (c Calls) lookupAST(file *ast.File, fset *token.FileSet) { 83 | ast.Inspect(file, func(n ast.Node) bool { 84 | if call, ok := n.(*ast.CallExpr); ok { 85 | var id *ast.Ident 86 | switch fun := call.Fun.(type) { 87 | case *ast.Ident: 88 | id = fun 89 | case *ast.SelectorExpr: 90 | id = fun.Sel 91 | } 92 | if id != nil && id.Name == "New" { 93 | if isPkgDot(call.Fun, "gitfs", "New") { 94 | project := stringExpr(call.Args[1]) 95 | pos := fset.Position(call.Pos()) 96 | if project == "" { 97 | log.Printf( 98 | "Skipping gitfs.New call in %s. Could not get project name from call.", 99 | pos) 100 | return false 101 | } 102 | 103 | // Mark that project is used. 104 | if c[project] == nil { 105 | c[project] = &Config{Project: project} 106 | } 107 | 108 | // Treat OptGlob call. 109 | patterns, err := findOptGlob(call.Args[2:]) 110 | if err != nil { 111 | log.Printf( 112 | "Failed getting glob options in %s, building without glob pattern: %s", 113 | pos, err) 114 | patterns = nil 115 | } 116 | if len(patterns) == 0 { 117 | // This call does not use pattern. Mark it so we will later load 118 | // all files. 119 | c[project].noPatterns = true 120 | } else { 121 | // Accumulate all the patterns that are used for all the places 122 | // that the project was used. 123 | c[project].globPatterns = append(c[project].globPatterns, patterns...) 124 | } 125 | } 126 | } 127 | } 128 | return true 129 | }) 130 | } 131 | 132 | // projectBinary retruns the binary encoded format of a single project. 133 | func loadBinary(provider fsProviderFn, c Config) string { 134 | log.Printf("Encoding project: %s", c.Project) 135 | fs, err := provider(c) 136 | if err != nil { 137 | log.Printf("Failed creating filesystem %q: %s", c.Project, err) 138 | return "" 139 | } 140 | b, err := encode(fs) 141 | if err != nil { 142 | log.Printf("Failed encoding filesystem %q: %s", c.Project, err) 143 | return "" 144 | } 145 | return string(b) 146 | } 147 | 148 | // findOptGlob takes arguments of the gitfs.New and looks for the 149 | // gitfs.OptGlob option. If it finds it, it returns the arguments that 150 | // were passed to that option. 151 | func findOptGlob(exprs []ast.Expr) ([]string, error) { 152 | for _, expr := range exprs { 153 | call, ok := expr.(*ast.CallExpr) 154 | if !ok { 155 | continue 156 | } 157 | if !isPkgDot(call.Fun, "gitfs", "OptGlob") { 158 | continue 159 | } 160 | var patterns []string 161 | for i, arg := range call.Args { 162 | pattern := stringExpr(arg) 163 | if pattern == "" { 164 | return nil, errors.Errorf( 165 | "can't understand string expression of OptGlob arg #%d with value %+v", 166 | i, arg) 167 | } 168 | patterns = append(patterns, pattern) 169 | } 170 | return patterns, nil 171 | } 172 | return nil, nil 173 | } 174 | 175 | // isPkgDot returns true if expr is `.` 176 | func isPkgDot(expr ast.Expr, pkg, name string) bool { 177 | sel, ok := expr.(*ast.SelectorExpr) 178 | return ok && isIdent(sel.X, pkg) && isIdent(sel.Sel, name) 179 | } 180 | 181 | // isIdent returns true if expr is ``. 182 | func isIdent(expr ast.Expr, ident string) bool { 183 | id, ok := expr.(*ast.Ident) 184 | return ok && id.Name == ident 185 | } 186 | 187 | // stringExpr takes the Expr that represent a string and converts it to its content. 188 | func stringExpr(expr ast.Expr) string { 189 | arg, ok := expr.(*ast.BasicLit) 190 | if !ok { 191 | return "" 192 | } 193 | return strings.Trim(arg.Value, `"`) 194 | } 195 | -------------------------------------------------------------------------------- /internal/binfs/load_test.go: -------------------------------------------------------------------------------- 1 | package binfs 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/posener/gitfs/internal/tree" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | const ( 14 | project1 = "github.com/a/b" 15 | project2 = "github.com/c/d" 16 | ) 17 | 18 | func TestLoadCalls(t *testing.T) { 19 | t.Parallel() 20 | got, err := LoadCalls("./testdata") 21 | require.NoError(t, err) 22 | 23 | want := Calls{ 24 | project1: &Config{Project: project1, noPatterns: true}, 25 | project2: &Config{Project: project2, globPatterns: []string{"foo", "*"}}, 26 | } 27 | 28 | assert.Equal(t, want, got) 29 | } 30 | 31 | func TestLoadCalls_patternNotFound(t *testing.T) { 32 | t.Parallel() 33 | 34 | _, err := LoadCalls("./nosuchpackage") 35 | assert.Error(t, err) 36 | } 37 | 38 | func TestGenerateBinaries(t *testing.T) { 39 | var p testProvider 40 | 41 | calls := Calls{ 42 | project1: &Config{Project: project1, noPatterns: true}, 43 | project2: &Config{Project: project2, globPatterns: []string{"foo", "*"}}, 44 | } 45 | 46 | // Generate binaries using the fake provider. 47 | binaries := GenerateBinaries(calls, p.provide) 48 | 49 | // Register the data that was created by loadBinaries. 50 | for _, project := range []string{project1, project2} { 51 | data := binaries[project] 52 | require.NotNil(t, data) 53 | Register(project, EncodeVersion, data) 54 | } 55 | 56 | // Check the data that was registered: 57 | for _, project := range []string{project1, project2} { 58 | assert.True(t, Match(project)) 59 | fs := Get(project) 60 | require.NotNil(t, fs) 61 | f, err := fs.Open("dir/file") 62 | assert.NoError(t, err) 63 | b, err := ioutil.ReadAll(f) 64 | f.Close() 65 | assert.NoError(t, err) 66 | assert.Equal(t, project, string(b)) 67 | } 68 | } 69 | 70 | type testProvider struct { 71 | // Saves with what projects the provider was called. 72 | calls []Config 73 | } 74 | 75 | func (p *testProvider) provide(c Config) (http.FileSystem, error) { 76 | p.calls = append(p.calls, c) 77 | return testFS(c.Project), nil 78 | } 79 | 80 | // testFS is a fake filesystem that contains only one file with 81 | // the provided content. 82 | func testFS(id string) http.FileSystem { 83 | t := make(tree.Tree) 84 | err := t.AddFileContent("dir/file", []byte(id)) 85 | if err != nil { 86 | panic(err) 87 | } 88 | return t 89 | } 90 | -------------------------------------------------------------------------------- /internal/binfs/testdata/testdata.go: -------------------------------------------------------------------------------- 1 | // A dummy package for binfs testing purposes that creates two gitfs filesystems. 2 | package main 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/posener/gitfs" 8 | ) 9 | 10 | func main() { 11 | ctx := context.Background() 12 | gitfs.New(ctx, "github.com/a/b") 13 | gitfs.New(ctx, "github.com/c/d", gitfs.OptGlob("foo", "*")) 14 | } 15 | -------------------------------------------------------------------------------- /internal/githubfs/getatree.go: -------------------------------------------------------------------------------- 1 | package githubfs 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "strings" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/posener/gitfs/internal/tree" 10 | ) 11 | 12 | // getATree gets github tree using Github's get-a-tree API: 13 | // https://developer.github.com/v3/git/trees/#get-a-tree. 14 | // The content provider returns the file content only when accessed. 15 | type getATree githubfs 16 | 17 | func (fs *getATree) get(ctx context.Context) (tree.Tree, error) { 18 | gitTree, _, err := fs.client.Git.GetTree(ctx, fs.owner, fs.repo, fs.ref, true) 19 | if err != nil { 20 | return nil, errors.Wrap(err, "get git tree") 21 | } 22 | t := make(tree.Tree) 23 | for _, entry := range gitTree.Entries { 24 | path := entry.GetPath() 25 | if fs.path != "" { 26 | if !strings.HasPrefix(path, fs.path) { 27 | continue 28 | } 29 | path = strings.TrimPrefix(path, fs.path) 30 | } 31 | 32 | var err error 33 | switch entry.GetType() { 34 | case "tree": // A directory. 35 | if !fs.glob.Match(path, true) { 36 | continue 37 | } 38 | err = t.AddDir(path) 39 | case "blob": // A file. 40 | if !fs.glob.Match(path, false) { 41 | continue 42 | } 43 | err = t.AddFile(path, entry.GetSize(), fs.contentLoader(entry.GetSHA())) 44 | } 45 | if err != nil { 46 | return nil, errors.Wrapf(err, "adding %s", path) 47 | } 48 | } 49 | return t, nil 50 | } 51 | 52 | // contentLoader gets content of git blob according to git sha of that blob. 53 | func (fs *getATree) contentLoader(sha string) func(context.Context) ([]byte, error) { 54 | return func(ctx context.Context) ([]byte, error) { 55 | blob, _, err := fs.client.Git.GetBlob(ctx, fs.owner, fs.repo, sha) 56 | if err != nil { 57 | return nil, errors.Wrap(err, "failed getting blob") 58 | } 59 | switch encoding := blob.GetEncoding(); encoding { 60 | case "base64": 61 | return base64.StdEncoding.DecodeString(blob.GetContent()) 62 | default: 63 | return nil, errors.Errorf("unexpected encoding: %s", encoding) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/githubfs/getcontents.go: -------------------------------------------------------------------------------- 1 | package githubfs 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "net/http" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/google/go-github/github" 11 | "github.com/pkg/errors" 12 | "github.com/posener/gitfs/internal/log" 13 | "github.com/posener/gitfs/internal/tree" 14 | ) 15 | 16 | // getContents gets github content using Github's get-contents API: 17 | // (https://developer.github.com/v3/repos/contents/#get-contents). 18 | // It gets both the tree and the content of the files together. 19 | type getContents githubfs 20 | 21 | func (fs *getContents) get(ctx context.Context) (tree.Tree, error) { 22 | downloader := recursiveGetContents{ 23 | getContents: fs, 24 | tree: make(tree.Tree), 25 | errors: make(chan error), 26 | } 27 | 28 | err := downloader.download(ctx) 29 | if err != nil { 30 | return nil, err 31 | } 32 | return downloader.tree, nil 33 | } 34 | 35 | // recursiveGetContents downloads an entire github tree using the Github get-contents API. 36 | // Since this API returns only a single-depth level of files, it runs recursively on each 37 | // directory. These recursive calls are done in parallel. 38 | type recursiveGetContents struct { 39 | *getContents 40 | tree tree.Tree 41 | mu sync.Mutex 42 | wg sync.WaitGroup 43 | errors chan error 44 | } 45 | 46 | // download an entire (sub)tree of a github project using the get-contents API. 47 | // The API returns an entire directory with all the files and download URL links. 48 | // The API is called recursively on all the directories, and download all the content of 49 | // all the files using the download URL. 50 | // Each recursive call is called in a goroutine, and each content download is called in 51 | // a goroutine. 52 | // The synchronization is done using mu, and waiting for all the goroutine to finish is 53 | // done using wg. 54 | func (gc *recursiveGetContents) download(ctx context.Context) error { 55 | gc.wg.Add(1) 56 | gc.check(gc.recursive(ctx, gc.path)) 57 | gc.wg.Wait() 58 | 59 | select { 60 | case err := <-gc.errors: 61 | return err 62 | default: 63 | return nil 64 | } 65 | } 66 | 67 | // recursice is a single recursive get-contents call. Before a call to recursive, 68 | // wg.Add(1) should be called. 69 | func (gc *recursiveGetContents) recursive(ctx context.Context, root string) error { 70 | defer gc.wg.Done() 71 | log.Printf("Using Github get-content API for path %q", root) 72 | file, entries, _, err := gc.client.Repositories.GetContents(ctx, gc.owner, gc.repo, root, gc.opt()) 73 | if err != nil { 74 | return errors.Wrap(err, "github get-contents") 75 | } 76 | 77 | // This API call may return entries or file, we check both cases. 78 | for _, entry := range entries { 79 | fullPath := entry.GetPath() 80 | fsPath := strings.TrimPrefix(fullPath, gc.path) 81 | 82 | switch entry.GetType() { 83 | case "dir": // A directory. 84 | if !gc.glob.Match(fsPath, true) { 85 | continue 86 | } 87 | gc.mu.Lock() 88 | err = gc.tree.AddDir(fsPath) 89 | gc.mu.Unlock() 90 | if err != nil { 91 | return errors.Wrapf(err, "adding %s", fsPath) 92 | } 93 | gc.wg.Add(1) 94 | go gc.check(gc.recursive(ctx, fullPath)) 95 | case "file": // A file. 96 | if !gc.glob.Match(fsPath, false) { 97 | continue 98 | } 99 | gc.wg.Add(1) 100 | go gc.check(gc.downloadContent(ctx, fsPath, entry.GetSize(), entry.GetDownloadURL())) 101 | } 102 | } 103 | 104 | if file != nil { 105 | path := file.GetPath() 106 | path = strings.TrimPrefix(path, gc.path) 107 | if !gc.glob.Match(path, false) { 108 | return nil 109 | } 110 | content, err := file.GetContent() 111 | if err != nil { 112 | return errors.Wrapf(err, "get content of %s", path) 113 | } 114 | gc.mu.Lock() 115 | err = gc.tree.AddFileContent(path, []byte(content)) 116 | gc.mu.Unlock() 117 | if err != nil { 118 | return errors.Wrapf(err, "adding %s", path) 119 | } 120 | } 121 | return nil 122 | } 123 | 124 | // downloadContent downloads content of a single file. Before a call to recursive, 125 | // wg.Add(1) should be called. 126 | func (gc *recursiveGetContents) downloadContent(ctx context.Context, path string, size int, downloadURL string) error { 127 | defer gc.wg.Done() 128 | content, err := gc.downloadURL(ctx, downloadURL) 129 | if err != nil { 130 | return errors.Wrapf(err, "get content from %s", downloadURL) 131 | } 132 | gc.mu.Lock() 133 | defer gc.mu.Unlock() 134 | return gc.tree.AddFileContent(path, content) 135 | } 136 | 137 | // downloadContent downloads a given URL. 138 | func (gc *recursiveGetContents) downloadURL(ctx context.Context, downloadURL string) ([]byte, error) { 139 | req, err := http.NewRequest(http.MethodGet, downloadURL, nil) 140 | if err != nil { 141 | return nil, errors.Wrap(err, "building request") 142 | } 143 | resp, err := gc.httpClient.Do(req.WithContext(ctx)) 144 | if err != nil { 145 | return nil, errors.Wrap(err, "performing http request") 146 | } 147 | defer resp.Body.Close() 148 | if resp.StatusCode != http.StatusOK { 149 | return nil, errors.Errorf("got status %d", resp.StatusCode) 150 | } 151 | return ioutil.ReadAll(resp.Body) 152 | } 153 | 154 | func (gc *recursiveGetContents) check(err error) { 155 | if err != nil { 156 | select { 157 | case gc.errors <- err: 158 | default: 159 | log.Printf("Failed sending error in channel", err) 160 | } 161 | } 162 | } 163 | 164 | // opt returns Github GetContent options. The expected ref, unlike other APIs, should not 165 | // have a 'heads/' or 'tags/' prefix. 166 | func (gc *recursiveGetContents) opt() *github.RepositoryContentGetOptions { 167 | if gc.ref == "" { 168 | return nil 169 | } 170 | ref := strings.TrimPrefix(gc.ref, "heads/") 171 | ref = strings.TrimPrefix(ref, "tags/") 172 | return &github.RepositoryContentGetOptions{Ref: ref} 173 | } 174 | -------------------------------------------------------------------------------- /internal/githubfs/githubfs.go: -------------------------------------------------------------------------------- 1 | package githubfs 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/google/go-github/github" 9 | "github.com/pkg/errors" 10 | "github.com/posener/gitfs/internal/glob" 11 | "github.com/posener/gitfs/internal/log" 12 | "github.com/posener/gitfs/internal/tree" 13 | ) 14 | 15 | type githubfs struct { 16 | *project 17 | client *github.Client 18 | httpClient *http.Client 19 | glob glob.Patterns 20 | } 21 | 22 | type treeGetter interface { 23 | get(context.Context) (tree.Tree, error) 24 | } 25 | 26 | // Match returns true if the given projectName matches a github project. 27 | func Match(projectName string) bool { 28 | return reGithubProject.MatchString(projectName) 29 | } 30 | 31 | // New returns a Tree for a given github project name. 32 | func New(ctx context.Context, client *http.Client, projectName string, prefetch bool, glob []string) (tree.Tree, error) { 33 | fs, err := newGithubFS(ctx, client, projectName, glob) 34 | if err != nil { 35 | return nil, err 36 | } 37 | var t tree.Tree 38 | 39 | // Log tree construction time. 40 | defer func(start time.Time) { 41 | log.Printf("Loaded project %q with %d files in %.1fs", projectName, len(t), time.Now().Sub(start).Seconds()) 42 | }(time.Now()) 43 | 44 | var getter treeGetter 45 | if prefetch { 46 | g := getContents(*fs) 47 | getter = &g 48 | } else { 49 | g := getATree(*fs) 50 | getter = &g 51 | } 52 | return getter.get(ctx) 53 | } 54 | 55 | func newGithubFS(ctx context.Context, client *http.Client, projectName string, patterns []string) (*githubfs, error) { 56 | g, err := glob.New(patterns...) 57 | if err != nil { 58 | return nil, err 59 | } 60 | if client == nil { 61 | client = http.DefaultClient 62 | } 63 | project, err := newProject(projectName) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | fs := &githubfs{ 69 | project: project, 70 | client: github.NewClient(client), 71 | httpClient: client, 72 | glob: g, 73 | } 74 | 75 | // Set ref to default branch in case it is empty. 76 | if fs.ref == "" { 77 | repo, _, err := fs.client.Repositories.Get(ctx, fs.owner, fs.repo) 78 | if err != nil { 79 | return nil, errors.Wrap(err, "get git repository") 80 | } 81 | fs.ref = "heads/" + repo.GetDefaultBranch() 82 | } 83 | return fs, nil 84 | } 85 | -------------------------------------------------------------------------------- /internal/githubfs/githubtfs_test.go: -------------------------------------------------------------------------------- 1 | package githubfs 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | "testing" 10 | 11 | "github.com/posener/gitfs/internal/testfs" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "golang.org/x/oauth2" 15 | ) 16 | 17 | var token = os.Getenv("GITHUB_TOKEN") 18 | 19 | func TestNew(t *testing.T) { 20 | t.Run("NoPrefetch", func(t *testing.T) { testfs.TestFS(t, testFileSystemNoPrefetch) }) 21 | t.Run("Prefetch", func(t *testing.T) { testfs.TestFS(t, testFileSystemPrefetch) }) 22 | } 23 | 24 | func TestNewWithGlob(t *testing.T) { 25 | tests := []struct { 26 | name string 27 | prefetch bool 28 | }{ 29 | {"no prefetch", false}, 30 | {"prefetch", true}, 31 | } 32 | 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | fs, err := testFilesystem(t, "github.com/posener/gitfs/internal/testdata", tt.prefetch, []string{"*/*1"}) 36 | require.NoError(t, err) 37 | _, err = fs.Open("d1/d11") 38 | assert.NoError(t, err) 39 | _, err = fs.Open("d1") 40 | assert.NoError(t, err) 41 | _, err = fs.Open("f01") 42 | assert.Error(t, err) 43 | }) 44 | } 45 | } 46 | 47 | type contexter interface { 48 | WithContext(context.Context) http.File 49 | } 50 | 51 | func TestOpen_cancelledContext(t *testing.T) { 52 | t.Parallel() 53 | fs, err := testFileSystemNoPrefetch(t, "github.com/posener/gitfs") 54 | require.NoError(t, err) 55 | 56 | ctx, cancel := context.WithCancel(context.Background()) 57 | cancel() 58 | f21, err := fs.Open("internal/testdata/f01") 59 | require.NoError(t, err) 60 | 61 | f21Ctx, ok := f21.(contexter) 62 | require.True(t, ok) 63 | f21 = f21Ctx.WithContext(ctx) 64 | 65 | _, err = ioutil.ReadAll(f21) 66 | require.Error(t, err) 67 | } 68 | 69 | func TestNewGithubProject(t *testing.T) { 70 | t.Parallel() 71 | p, err := newGithubFS(context.Background(), mockClient(), "github.com/x/y", nil) 72 | require.NoError(t, err) 73 | assert.Equal(t, "heads/master", p.ref) 74 | } 75 | 76 | func testFileSystemNoPrefetch(t *testing.T, project string) (http.FileSystem, error) { 77 | return testFilesystem(t, project, false, nil) 78 | } 79 | 80 | func testFileSystemPrefetch(t *testing.T, project string) (http.FileSystem, error) { 81 | return testFilesystem(t, project, true, nil) 82 | } 83 | 84 | func testFilesystem(t *testing.T, project string, prefetch bool, glob []string) (http.FileSystem, error) { 85 | t.Helper() 86 | if token == "" { 87 | t.Skip("no github token provided") 88 | } 89 | c := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})) 90 | return New(context.Background(), c, project, prefetch, glob) 91 | } 92 | 93 | func mockClient() *http.Client { 94 | return &http.Client{Transport: &mockTransport{}} 95 | } 96 | 97 | type mockTransport struct{} 98 | 99 | func (*mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { 100 | switch { 101 | case req.Method == http.MethodGet && req.URL.Path == "/repos/x/y": 102 | return &http.Response{ 103 | StatusCode: http.StatusOK, 104 | Header: make(http.Header), 105 | Body: ioutil.NopCloser(bytes.NewReader([]byte(`{"default_branch":"master"}`))), 106 | Request: req, 107 | }, nil 108 | default: 109 | return &http.Response{ 110 | StatusCode: http.StatusNotFound, 111 | Header: make(http.Header), 112 | Body: ioutil.NopCloser(bytes.NewReader([]byte(`{}`))), 113 | Request: req, 114 | }, nil 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /internal/githubfs/project.go: -------------------------------------------------------------------------------- 1 | package githubfs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | reGithubProject = regexp.MustCompile(`^github\.com/([^@/]+)/([^@/]+)(/([^@]*))?(@([^#]+))?$`) 12 | reSemver = regexp.MustCompile(`^v?\d+(\.\d+){0,2}$`) 13 | ) 14 | 15 | type project struct { 16 | owner string 17 | repo string 18 | ref string 19 | path string 20 | } 21 | 22 | // newProject parses project name into the different components 23 | // it is composed of. 24 | func newProject(projectName string) (p *project, err error) { 25 | matches := reGithubProject.FindStringSubmatch(projectName) 26 | if len(matches) < 2 { 27 | err = fmt.Errorf("bad project name: %s", projectName) 28 | return 29 | } 30 | 31 | p = &project{ 32 | owner: matches[1], 33 | repo: matches[2], 34 | path: matches[4], 35 | ref: matches[6], 36 | } 37 | 38 | // Add "/" suffix to path. 39 | if len(p.path) > 0 && p.path[len(p.path)-1] != '/' { 40 | p.path = p.path + "/" 41 | } 42 | 43 | // If ref is Semver, add 'tags/' prefix to make it a valid ref. 44 | if reSemver.MatchString(p.ref) { 45 | p.ref = "tags/" + p.ref 46 | } 47 | 48 | err = verifyRef(p.ref) 49 | return 50 | } 51 | 52 | func verifyRef(ref string) error { 53 | if ref != "" && !strings.HasPrefix(ref, "heads/") && !strings.HasPrefix(ref, "tags/") { 54 | return errors.New("ref must have a 'heads/' or 'tags/' prefix") 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/githubfs/project_test.go: -------------------------------------------------------------------------------- 1 | package githubfs 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGithubNewProject(t *testing.T) { 11 | t.Parallel() 12 | tests := []struct { 13 | path string 14 | want project 15 | }{ 16 | { 17 | path: "github.com/x/y@tags/v1", 18 | want: project{owner: "x", repo: "y", ref: "tags/v1"}, 19 | }, 20 | { 21 | path: "github.com/x/y@heads/foo", 22 | want: project{owner: "x", repo: "y", ref: "heads/foo"}, 23 | }, 24 | { 25 | path: "github.com/x/y", 26 | want: project{owner: "x", repo: "y", ref: ""}, 27 | }, 28 | { 29 | path: "github.com/x/y@v1", 30 | want: project{owner: "x", repo: "y", ref: "tags/v1"}, 31 | }, 32 | { 33 | path: "github.com/x/y@v1.2", 34 | want: project{owner: "x", repo: "y", ref: "tags/v1.2"}, 35 | }, 36 | { 37 | path: "github.com/x/y@v1.2.3", 38 | want: project{owner: "x", repo: "y", ref: "tags/v1.2.3"}, 39 | }, 40 | { 41 | path: "github.com/x/y@1", 42 | want: project{owner: "x", repo: "y", ref: "tags/1"}, 43 | }, 44 | { 45 | path: "github.com/x/y@1.2", 46 | want: project{owner: "x", repo: "y", ref: "tags/1.2"}, 47 | }, 48 | { 49 | path: "github.com/x/y@1.2.3", 50 | want: project{owner: "x", repo: "y", ref: "tags/1.2.3"}, 51 | }, 52 | { 53 | path: "github.com/x/y/static/path", 54 | want: project{owner: "x", repo: "y", path: "static/path/"}, 55 | }, 56 | { 57 | path: "github.com/x/y/static@v1.2.3", 58 | want: project{owner: "x", repo: "y", ref: "tags/v1.2.3", path: "static/"}, 59 | }, 60 | } 61 | 62 | for _, tt := range tests { 63 | t.Run(tt.path, func(t *testing.T) { 64 | got, err := newProject(tt.path) 65 | require.NoError(t, err) 66 | assert.Equal(t, &tt.want, got) 67 | }) 68 | } 69 | } 70 | 71 | func TestGithubProjectProperties_error(t *testing.T) { 72 | t.Parallel() 73 | paths := []string{ 74 | // Not github.com 75 | "google.com/x/y@tags/v1", 76 | // Not .com 77 | "github/x/y@tags/v1", 78 | // Missing repo 79 | "github.com/x@tags/v1", 80 | // Missing owner and repo 81 | "github.com@tags/v1", 82 | // Invalid reference 83 | "github.com/x/y@x1", 84 | // Invalid semvers 85 | "github.com/x/y@v1.", 86 | "github.com/x/y@v1.2.3.4", 87 | "github.com/x/y@1.", 88 | "github.com/x/y@1.2.3.4", 89 | } 90 | 91 | for _, path := range paths { 92 | t.Run(path, func(t *testing.T) { 93 | p, err := newProject(path) 94 | assert.Error(t, err, "Got project=%+v", p) 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/glob/glob.go: -------------------------------------------------------------------------------- 1 | package glob 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | // Patterns can glob-match files or directories. 11 | type Patterns []string 12 | 13 | // New returns a new glob pattern. It returns an error if any of the 14 | // patterns is invalid. 15 | func New(patterns ...string) (Patterns, error) { 16 | if err := checkPatterns(patterns); err != nil { 17 | return nil, err 18 | } 19 | return Patterns(patterns), nil 20 | } 21 | 22 | // Match a path to the defined patterns. If it is a file a full match 23 | // is required. If it is a directory, only matching a prefix of any of 24 | // the patterns is required. 25 | func (p Patterns) Match(path string, isDir bool) bool { 26 | if len(p) == 0 { 27 | return true 28 | } 29 | path = filepath.Clean(path) 30 | return (isDir && p.matchPrefix(path)) || (!isDir && p.matchFull(path)) 31 | } 32 | 33 | // matchFull finds a matching of the whole name to any of the patterns. 34 | func (p Patterns) matchFull(name string) bool { 35 | for _, pattern := range p { 36 | if ok, _ := filepath.Match(pattern, name); ok { 37 | return true 38 | } 39 | } 40 | return false 41 | } 42 | 43 | // matchPrefix finds a matching of prefix to a prefix of any of the patterns. 44 | func (p Patterns) matchPrefix(prefix string) bool { 45 | parts := strings.Split(prefix, string(filepath.Separator)) 46 | nextPattern: 47 | for _, pattern := range p { 48 | patternParts := strings.Split(pattern, string(filepath.Separator)) 49 | if len(patternParts) < len(parts) { 50 | continue 51 | } 52 | for i := 0; i < len(parts); i++ { 53 | if ok, _ := filepath.Match(patternParts[i], parts[i]); !ok { 54 | continue nextPattern 55 | } 56 | } 57 | return true 58 | } 59 | return false 60 | } 61 | 62 | // checkPattens checks the validity of the patterns. 63 | func checkPatterns(patterns []string) error { 64 | var badPatterns []string 65 | for _, pattern := range patterns { 66 | _, err := filepath.Match(pattern, "x") 67 | if err != nil { 68 | badPatterns = append(badPatterns, pattern) 69 | return errors.Wrap(err, pattern) 70 | } 71 | } 72 | if len(badPatterns) > 0 { 73 | return errors.Wrap(filepath.ErrBadPattern, strings.Join(badPatterns, ", ")) 74 | } 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/glob/glob_test.go: -------------------------------------------------------------------------------- 1 | package glob 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestMatch(t *testing.T) { 11 | t.Parallel() 12 | tests := []struct { 13 | pattern []string 14 | name string 15 | isDir bool 16 | }{ 17 | // No pattern should match anything. 18 | {name: "foo"}, 19 | {pattern: []string{"foo"}, name: "foo"}, 20 | {pattern: []string{"*"}, name: "foo"}, 21 | {pattern: []string{"foo"}, name: "./foo"}, 22 | {pattern: []string{"foo"}, name: "foo/"}, 23 | {pattern: []string{"foo"}, name: "./foo/"}, 24 | {pattern: []string{"foo", "bar"}, name: "foo"}, 25 | {pattern: []string{"bar", "foo"}, name: "foo"}, 26 | {pattern: []string{"*/*"}, name: "foo/bar"}, 27 | {pattern: []string{"*/*"}, name: "./foo/bar"}, 28 | {pattern: []string{"*/*"}, name: "foo/bar/"}, 29 | {pattern: []string{"*/*"}, name: "./foo/bar/"}, 30 | {pattern: []string{"*/*"}, name: "foo", isDir: true}, 31 | {pattern: []string{"*"}, name: "foo", isDir: true}, 32 | {pattern: []string{"foo"}, name: "foo", isDir: true}, 33 | } 34 | 35 | for _, tt := range tests { 36 | p, err := New(tt.pattern...) 37 | require.NoError(t, err) 38 | assert.True(t, p.Match(tt.name, tt.isDir)) 39 | } 40 | } 41 | 42 | func TestMatch_noMatch(t *testing.T) { 43 | t.Parallel() 44 | tests := []struct { 45 | pattern []string 46 | name string 47 | isDir bool 48 | }{ 49 | {pattern: []string{"f"}, name: "foo"}, 50 | {pattern: []string{"f", "bar"}, name: "foo"}, 51 | {pattern: []string{"bar", "f"}, name: "foo"}, 52 | {pattern: []string{"*/*"}, name: "foo"}, 53 | {pattern: []string{"*/*"}, name: "./foo"}, 54 | {pattern: []string{"*/*"}, name: "foo/"}, 55 | {pattern: []string{"*/*"}, name: "./foo/"}, 56 | {pattern: []string{"*"}, name: "foo/bar"}, 57 | {pattern: []string{"*"}, name: "./foo/bar"}, 58 | {pattern: []string{"*"}, name: "foo/bar/"}, 59 | {pattern: []string{"*"}, name: "./foo/bar/"}, 60 | {pattern: []string{"*"}, name: "foo/bar", isDir: true}, 61 | {pattern: []string{"*"}, name: "./foo/bar", isDir: true}, 62 | {pattern: []string{"*"}, name: "foo/bar/", isDir: true}, 63 | {pattern: []string{"*"}, name: "./foo/bar/", isDir: true}, 64 | } 65 | 66 | for _, tt := range tests { 67 | p, err := New(tt.pattern...) 68 | require.NoError(t, err) 69 | assert.False(t, p.Match(tt.name, tt.isDir)) 70 | } 71 | } 72 | 73 | func TestNew_badPattern(t *testing.T) { 74 | t.Parallel() 75 | _, err := New("[") // Missing closing bracket. 76 | assert.Error(t, err) 77 | } 78 | -------------------------------------------------------------------------------- /internal/localfs/localfs.go: -------------------------------------------------------------------------------- 1 | package localfs 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/pkg/errors" 12 | "github.com/go-git/go-billy/v5/osfs" 13 | git "github.com/go-git/go-git/v5" 14 | "github.com/go-git/go-git/v5/plumbing/cache" 15 | "github.com/go-git/go-git/v5/storage/filesystem" 16 | ) 17 | 18 | // New returns a Tree for a given github project name. 19 | func New(projectName string, localPath string) (http.FileSystem, error) { 20 | gitRoot, err := lookupGitRoot(localPath) 21 | if err != nil { 22 | return nil, errors.Wrap(err, "git root not found") 23 | } 24 | subDir, err := computeSubdir(projectName, gitRoot) 25 | if err != nil { 26 | return nil, errors.Wrap(err, "git repository does not match project") 27 | } 28 | return http.Dir(filepath.Join(gitRoot, subDir)), nil 29 | } 30 | 31 | // match validates tha the git repository has a remote URL that matches 32 | // the given project. 33 | func computeSubdir(projectName, gitRoot string) (string, error) { 34 | projectName = cleanRevision(projectName) 35 | r, err := gitRepo(gitRoot) 36 | if err != nil { 37 | return "", err 38 | } 39 | remotes, err := r.Remotes() 40 | if err != nil { 41 | return "", err 42 | } 43 | for _, remote := range remotes { 44 | for _, url := range remote.Config().URLs { 45 | project := urlProjectName(url) 46 | if projectName == project { 47 | return "", nil 48 | } 49 | if strings.HasPrefix(projectName, project+"/") { 50 | return strings.TrimPrefix(projectName, project+"/"), nil 51 | } 52 | } 53 | } 54 | return "", errors.New("non of remote URLs matched") 55 | } 56 | 57 | func cleanRevision(projectName string) string { 58 | i := strings.Index(projectName, "@") 59 | if i < 0 { 60 | return projectName 61 | } 62 | return projectName[:i] 63 | } 64 | 65 | func gitRepo(path string) (*git.Repository, error) { 66 | // We instantiate a new repository targeting the given path (the .git folder) 67 | fs := osfs.New(path) 68 | if _, err := fs.Stat(git.GitDirName); err == nil { 69 | fs, err = fs.Chroot(git.GitDirName) 70 | if err != nil { 71 | return nil, err 72 | } 73 | } 74 | 75 | s := filesystem.NewStorageWithOptions(fs, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true}) 76 | return git.Open(s, fs) 77 | } 78 | 79 | func lookupGitRoot(path string) (string, error) { 80 | path, err := filepath.Abs(path) 81 | if err != nil { 82 | return "", err 83 | } 84 | for path != "" { 85 | if _, err := os.Stat(filepath.Join(path, git.GitDirName)); err == nil { 86 | return path, nil 87 | } 88 | path, _ = filepath.Split(path) 89 | if len(path) > 0 && path[len(path)-1] == filepath.Separator { 90 | path = path[:len(path)-1] 91 | } 92 | } 93 | return "", errors.New("not git repository") 94 | } 95 | 96 | func urlProjectName(urlStr string) string { 97 | url, err := url.Parse(urlStr) 98 | if err != nil { 99 | panic(fmt.Sprintf("failed parsing URL: %s", urlStr)) 100 | } 101 | url.Path = strings.TrimSuffix(url.Path, ".git") 102 | return url.Host + url.Path 103 | } 104 | -------------------------------------------------------------------------------- /internal/localfs/localfs_test.go: -------------------------------------------------------------------------------- 1 | package localfs 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/posener/gitfs/internal/testfs" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestNew(t *testing.T) { 15 | t.Parallel() 16 | testfs.TestFS(t, func(t *testing.T, project string) (http.FileSystem, error) { 17 | return New(project, ".") 18 | }) 19 | } 20 | 21 | func TestComputeSubdir(t *testing.T) { 22 | t.Parallel() 23 | gitRoot, err := lookupGitRoot(".") 24 | require.NoError(t, err) 25 | 26 | tests := []struct { 27 | project string 28 | wantSubDir string 29 | }{ 30 | // Simple case. 31 | {project: "github.com/posener/gitfs", wantSubDir: ""}, 32 | // Any ref should be omitted. 33 | {project: "github.com/posener/gitfs@123", wantSubDir: ""}, 34 | // With subdirectories. 35 | {project: "github.com/posener/gitfs/internal@123", wantSubDir: "internal"}, 36 | {project: "github.com/posener/gitfs/internal/testdata", wantSubDir: "internal/testdata"}, 37 | } 38 | for _, tt := range tests { 39 | t.Run(tt.project, func(t *testing.T) { 40 | subDir, err := computeSubdir(tt.project, gitRoot) 41 | require.NoError(t, err) 42 | assert.Equal(t, tt.wantSubDir, subDir) 43 | }) 44 | } 45 | } 46 | 47 | func TestComputeSubdir_failure(t *testing.T) { 48 | t.Parallel() 49 | gitRoot, err := lookupGitRoot(".") 50 | require.NoError(t, err) 51 | 52 | tests := []struct { 53 | project string 54 | path string 55 | }{ 56 | // Should not have a .git suffix. 57 | {project: "github.com/posener/gitfs.git", path: gitRoot}, 58 | // Wrong domain. 59 | {project: "git.com/posener/gitfs", path: gitRoot}, 60 | // Correct project but not a repository directory. 61 | {project: "github.com/posener/gitfs", path: "/tmp"}, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.project, func(t *testing.T) { 66 | _, err := computeSubdir(tt.project, tt.path) 67 | assert.Error(t, err) 68 | }) 69 | } 70 | } 71 | 72 | func TestCleanRevision(t *testing.T) { 73 | t.Parallel() 74 | assert.Equal(t, "x", cleanRevision("x")) 75 | assert.Equal(t, "x", cleanRevision("x@")) 76 | assert.Equal(t, "x", cleanRevision("x@v")) 77 | } 78 | 79 | func TestLookupGitRoot(t *testing.T) { 80 | t.Parallel() 81 | gitRoot, err := filepath.Abs("../..") 82 | require.NoError(t, err) 83 | 84 | // Check from current directory (not a git root) 85 | path, err := lookupGitRoot(".") 86 | require.NoError(t, err) 87 | assert.Equal(t, gitRoot, path) 88 | 89 | // Check from git root 90 | os.Chdir(gitRoot) 91 | path, err = lookupGitRoot(gitRoot) 92 | require.NoError(t, err) 93 | assert.Equal(t, gitRoot, path) 94 | 95 | // Check from /tmp - not a git repository 96 | path, err = lookupGitRoot("/tmp") 97 | assert.Error(t, err) 98 | } 99 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | // Package log enables controlling gitfs logging. 2 | package log 3 | 4 | type Logger interface { 5 | Printf(format string, v ...interface{}) 6 | } 7 | 8 | var Log Logger = nil 9 | 10 | func Printf(format string, v ...interface{}) { 11 | if Log == nil { 12 | return 13 | } 14 | Log.Printf(format, v...) 15 | } 16 | -------------------------------------------------------------------------------- /internal/testdata/d1/d11/f111: -------------------------------------------------------------------------------- 1 | f111 content -------------------------------------------------------------------------------- /internal/testdata/d2/f21: -------------------------------------------------------------------------------- 1 | f21 content -------------------------------------------------------------------------------- /internal/testdata/f01: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posener/gitfs/036be12fadb3bca2e577826d985e2d9ae66f448a/internal/testdata/f01 -------------------------------------------------------------------------------- /internal/testfs/testfs.go: -------------------------------------------------------------------------------- 1 | package testfs 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestFS(t *testing.T, fsFactory func(*testing.T, string) (http.FileSystem, error)) { 14 | tests := []struct { 15 | project string 16 | root string 17 | }{ 18 | { 19 | project: "github.com/posener/gitfs", 20 | root: "internal/testdata", 21 | }, 22 | { 23 | project: "github.com/posener/gitfs/internal", 24 | root: "testdata", 25 | }, 26 | } 27 | for _, tt := range tests { 28 | t.Run(tt.project, func(t *testing.T) { 29 | fs, err := fsFactory(t, tt.project) 30 | require.NoError(t, err) 31 | fst := fsTest{ 32 | FileSystem: fs, 33 | root: tt.root, 34 | } 35 | t.Run("DirContains", fst.dirContains) 36 | t.Run("DirNotContains", fst.dirNotContains) 37 | t.Run("FileContent", fst.fileContent) 38 | t.Run("NotExistingFile", fst.notExistingFile) 39 | }) 40 | } 41 | 42 | t.Run("NotSuchProject", func(t *testing.T) { 43 | _, err := fsFactory(t, "git.com/posener/gitfs") 44 | assert.Error(t, err) 45 | }) 46 | } 47 | 48 | type fsTest struct { 49 | http.FileSystem 50 | root string 51 | } 52 | 53 | func (fs *fsTest) dirContains(t *testing.T) { 54 | t.Parallel() 55 | 56 | tests := []struct { 57 | path string 58 | contains string 59 | isDir bool 60 | }{ 61 | {path: fs.root, contains: "d1", isDir: true}, 62 | {path: fs.root, contains: "d2", isDir: true}, 63 | {path: fs.root, contains: "f01"}, 64 | {path: fs.root + "/d1", contains: "d11", isDir: true}, 65 | {path: fs.root + "/d1/d11", contains: "f111"}, 66 | {path: fs.root + "/d2", contains: "f21"}, 67 | } 68 | 69 | for _, tt := range tests { 70 | t.Run(tt.path+":"+tt.contains, func(t *testing.T) { 71 | f, err := fs.Open(tt.path) 72 | require.NoError(t, err) 73 | info := requireContains(t, f, tt.contains) 74 | assert.Equal(t, tt.isDir, info.IsDir()) 75 | }) 76 | } 77 | } 78 | 79 | func (fs *fsTest) dirNotContains(t *testing.T) { 80 | t.Parallel() 81 | 82 | tests := []struct { 83 | path string 84 | notContains string 85 | }{ 86 | {path: fs.root, notContains: "d11"}, 87 | {path: fs.root, notContains: "f111"}, 88 | {path: fs.root, notContains: "d1/d11"}, 89 | } 90 | 91 | for _, tt := range tests { 92 | t.Run(tt.path+":"+tt.notContains, func(t *testing.T) { 93 | f, err := fs.Open(tt.path) 94 | require.NoError(t, err) 95 | assertNotContains(t, f, tt.notContains) 96 | }) 97 | } 98 | } 99 | 100 | func (fs *fsTest) fileContent(t *testing.T) { 101 | t.Parallel() 102 | 103 | tests := []struct { 104 | path string 105 | content string 106 | }{ 107 | {path: fs.root + "/f01", content: ""}, 108 | {path: fs.root + "/d1/d11/f111", content: "f111 content"}, 109 | {path: fs.root + "/d2/f21", content: "f21 content"}, 110 | } 111 | 112 | for _, tt := range tests { 113 | t.Run(tt.path, func(t *testing.T) { 114 | f, err := fs.Open(tt.path) 115 | require.NoError(t, err) 116 | assertFileContent(t, f, []byte(tt.content)) 117 | }) 118 | } 119 | } 120 | 121 | func (fs *fsTest) notExistingFile(t *testing.T) { 122 | t.Parallel() 123 | _, err := fs.Open(fs.root + "/nosuchfile") 124 | assert.Error(t, err) 125 | } 126 | 127 | func requireContains(t *testing.T, d http.File, contains string) os.FileInfo { 128 | t.Helper() 129 | files, err := d.Readdir(-1) 130 | require.NoError(t, err) 131 | for _, f := range files { 132 | if f.Name() == contains { 133 | return f 134 | } 135 | } 136 | t.Fatalf("FS did not contain file %q", contains) 137 | return nil 138 | } 139 | 140 | func assertNotContains(t *testing.T, d http.File, notContains string) { 141 | t.Helper() 142 | files, err := d.Readdir(-1) 143 | require.NoError(t, err) 144 | for _, f := range files { 145 | if f.Name() == notContains { 146 | t.Errorf("FS contains file %q", notContains) 147 | return 148 | } 149 | } 150 | return 151 | } 152 | 153 | func assertFileContent(t *testing.T, f http.File, content []byte) { 154 | t.Helper() 155 | b, err := ioutil.ReadAll(f) 156 | require.NoError(t, err) 157 | assert.Equal(t, content, b) 158 | } 159 | -------------------------------------------------------------------------------- /internal/tree/dir.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "time" 7 | ) 8 | 9 | func newDir(name string) *dir { 10 | if name == "" { 11 | name = "." 12 | } 13 | return &dir{name: name} 14 | } 15 | 16 | // dir is an Opener for a directory. It is also the http.File. 17 | type dir struct { 18 | name string 19 | files []os.FileInfo 20 | } 21 | 22 | func (d *dir) Open() http.File { 23 | return d 24 | } 25 | 26 | func (d *dir) add(f os.FileInfo) { 27 | d.files = append(d.files, f) 28 | } 29 | 30 | func (d *dir) Close() error { 31 | return nil 32 | } 33 | func (d *dir) Read([]byte) (int, error) { 34 | return 0, nil 35 | } 36 | func (d *dir) Seek(int64, int) (int64, error) { 37 | return 0, nil 38 | } 39 | 40 | func (d *dir) Readdir(n int) ([]os.FileInfo, error) { 41 | if n <= 0 || n >= len(d.files) { 42 | return d.files, nil 43 | } 44 | return d.files[:n], nil 45 | } 46 | 47 | func (d *dir) Stat() (os.FileInfo, error) { 48 | return d, nil 49 | } 50 | 51 | func (d *dir) Name() string { 52 | return d.name 53 | } 54 | func (d *dir) Size() int64 { 55 | return 0 56 | } 57 | func (d *dir) Mode() os.FileMode { 58 | return os.ModeDir 59 | } 60 | func (d *dir) ModTime() time.Time { 61 | return time.Time{} 62 | } 63 | 64 | func (d *dir) IsDir() bool { 65 | return true 66 | } 67 | 68 | func (d *dir) Sys() interface{} { 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /internal/tree/file.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "net/http" 7 | "os" 8 | "sync" 9 | "time" 10 | 11 | "github.com/posener/gitfs/internal/log" 12 | ) 13 | 14 | func newFile(name string, size int64, load Loader) *file { 15 | return &file{name: name, size: size, load: load} 16 | } 17 | 18 | // file is an Opener for a file object. 19 | type file struct { 20 | name string 21 | size int64 22 | load Loader 23 | 24 | content []byte 25 | mu sync.Mutex 26 | } 27 | 28 | func (f *file) Open() http.File { 29 | return &lazyReader{file: f, ctx: context.Background()} 30 | } 31 | 32 | func (f *file) Stat() (os.FileInfo, error) { 33 | return f, nil 34 | } 35 | 36 | func (f *file) Name() string { 37 | return f.name 38 | } 39 | 40 | func (f *file) Size() int64 { 41 | return f.size 42 | } 43 | 44 | func (*file) Mode() os.FileMode { 45 | return 0 46 | } 47 | 48 | func (*file) ModTime() time.Time { 49 | return time.Time{} 50 | } 51 | 52 | func (*file) IsDir() bool { 53 | return false 54 | } 55 | 56 | func (*file) Sys() interface{} { 57 | return nil 58 | } 59 | 60 | func (*file) Readdir(count int) ([]os.FileInfo, error) { 61 | return nil, nil 62 | } 63 | 64 | func (f *file) loadContent(ctx context.Context) error { 65 | f.mu.Lock() 66 | defer f.mu.Unlock() 67 | if f.content != nil { 68 | return nil 69 | } 70 | start := time.Now() 71 | buf, err := f.load(ctx) 72 | if err != nil { 73 | return err 74 | } 75 | f.content = buf 76 | log.Printf("Loaded file %s in %.1fs", f.name, time.Now().Sub(start).Seconds()) 77 | return nil 78 | } 79 | 80 | // lazyReader is the http.File for a file. It loads lazily file content 81 | // only when Read or Seek operations are performed. 82 | type lazyReader struct { 83 | *file 84 | reader *bytes.Reader 85 | ctx context.Context 86 | mu sync.Mutex 87 | } 88 | 89 | func (r *lazyReader) lazy() error { 90 | if err := r.loadContent(r.ctx); err != nil { 91 | return err 92 | } 93 | r.mu.Lock() 94 | defer r.mu.Unlock() 95 | if r.reader == nil { 96 | r.reader = bytes.NewReader(r.content) 97 | } 98 | return nil 99 | } 100 | 101 | func (r *lazyReader) WithContext(ctx context.Context) http.File { 102 | return r.withContext(ctx) 103 | } 104 | 105 | func (r lazyReader) withContext(ctx context.Context) *lazyReader { 106 | r.ctx = ctx 107 | return &r 108 | } 109 | 110 | func (r *lazyReader) Close() error { 111 | r.mu.Lock() 112 | defer r.mu.Unlock() 113 | r.reader = nil 114 | r.ctx = context.Background() 115 | return nil 116 | } 117 | 118 | func (r *lazyReader) Read(p []byte) (int, error) { 119 | if err := r.lazy(); err != nil { 120 | return 0, err 121 | } 122 | if err := r.ctx.Err(); err != nil { 123 | return 0, err 124 | } 125 | return r.reader.Read(p) 126 | } 127 | 128 | func (r *lazyReader) Seek(offset int64, whence int) (int64, error) { 129 | if err := r.lazy(); err != nil { 130 | return 0, err 131 | } 132 | return r.reader.Seek(offset, whence) 133 | } 134 | -------------------------------------------------------------------------------- /internal/tree/tree.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/posener/gitfs/internal/log" 12 | ) 13 | 14 | // Opener is an interface for a directory or a file provider. 15 | type Opener interface { 16 | // Open returns a file/dir. 17 | Open() http.File 18 | // Stat returns information about the file/dir. 19 | Stat() (os.FileInfo, error) 20 | // Readdir retruns list of file info contained in a directory. 21 | // Preforming Readdir on a file returns nil, nil. 22 | Readdir(count int) ([]os.FileInfo, error) 23 | } 24 | 25 | // Tree maps a file path to a file provider. 26 | // It implements http.FileSystem. 27 | type Tree map[string]Opener 28 | 29 | // Loader is a function that loads file content. If the context id done 30 | // this function should return an error. 31 | type Loader func(context.Context) ([]byte, error) 32 | 33 | // Open is the implementation of http.FileSystem. 34 | func (t Tree) Open(name string) (http.File, error) { 35 | path := strings.Trim(name, "/") 36 | 37 | opener := t[path] 38 | if opener == nil { 39 | if path == "" { 40 | // No files were added yet, return empty root directory. 41 | return newDir("/"), nil 42 | } 43 | log.Printf("File %s not found", name) 44 | return nil, os.ErrNotExist 45 | } 46 | if !valid(name, opener.Stat) { 47 | log.Printf("File %s is invalid", name) 48 | return nil, os.ErrInvalid 49 | 50 | } 51 | 52 | return opener.Open(), nil 53 | } 54 | 55 | // AddDir adds a directory to a tree. It also adds recursively all the 56 | // parent directories. 57 | func (t Tree) AddDir(path string) error { 58 | path = cleanPath(path) 59 | if t[path] != nil { 60 | if _, ok := t[path].(*dir); !ok { 61 | return fmt.Errorf("trying to override %T on path %s with a dir", t[path], path) 62 | } 63 | return nil 64 | } 65 | dirPath, name := filepath.Split(path) 66 | dirPath = cleanPath(dirPath) 67 | d := newDir(name) 68 | t[path] = d 69 | 70 | // Skip setting parent directory for root directory. 71 | if name == "" { 72 | return nil 73 | } 74 | 75 | // Add parent directory, and add the current directory to the parent. 76 | err := t.AddDir(dirPath) 77 | if err != nil { 78 | return err 79 | } 80 | st, _ := d.Stat() 81 | parent, ok := t[dirPath].(*dir) 82 | if !ok { 83 | panic(fmt.Sprintf("Expected %q to be *dir, got %T", dirPath, t[dirPath])) 84 | } 85 | parent.add(st) 86 | return nil 87 | } 88 | 89 | // AddFile adds a file to a tree. It also adds recursively all the 90 | // parent directories. 91 | func (t Tree) AddFile(path string, size int, load Loader) error { 92 | path = cleanPath(path) 93 | if t[path] != nil { 94 | if _, ok := t[path].(*file); !ok { 95 | return fmt.Errorf("trying to override %T on path %s with a file", t[path], path) 96 | } 97 | return nil 98 | } 99 | dirPath, name := filepath.Split(path) 100 | dirPath = cleanPath(dirPath) 101 | f := newFile(name, int64(size), load) 102 | t[path] = f 103 | 104 | // Add parent directory, and add the current file to the parent. 105 | err := t.AddDir(dirPath) 106 | if err != nil { 107 | return err 108 | } 109 | parent, ok := t[dirPath].(*dir) 110 | if !ok { 111 | panic(fmt.Sprintf("Expected %q to be *dir, got %T", dirPath, t[dirPath])) 112 | } 113 | parent.add(f) 114 | return nil 115 | } 116 | 117 | // AddFileContent adds a file that its content is already available. 118 | func (t Tree) AddFileContent(path string, content []byte) error { 119 | return t.AddFile(path, len(content), func(ctx context.Context) ([]byte, error) { 120 | if err := ctx.Err(); err != nil { 121 | return nil, err 122 | } 123 | return content, nil 124 | }) 125 | } 126 | 127 | func valid(name string, info func() (os.FileInfo, error)) bool { 128 | expectingDir := len(name) > 0 && name[len(name)-1] == '/' 129 | if expectingDir { 130 | if info, err := info(); err != nil || !info.IsDir() { 131 | return false 132 | } 133 | } 134 | return true 135 | } 136 | 137 | func cleanPath(path string) string { 138 | return strings.Trim(path, "/") 139 | } 140 | -------------------------------------------------------------------------------- /internal/tree/tree_test.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "sync" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestTree(t *testing.T) { 19 | t.Parallel() 20 | 21 | tr := make(Tree) 22 | require.NoError(t, tr.AddDir("a")) 23 | assertDir(t, tr, "") 24 | assertDir(t, tr, "a") 25 | assertDirContains(t, tr, "", "a") 26 | 27 | require.NoError(t, tr.AddDir("a/b")) 28 | assertDir(t, tr, "a") 29 | assertDir(t, tr, "a/b") 30 | assertDirContains(t, tr, "a", "b") 31 | 32 | require.NoError(t, tr.AddDir("a/bb")) 33 | assertDir(t, tr, "a") 34 | assertDir(t, tr, "a/b") 35 | assertDir(t, tr, "a/bb") 36 | assertDirContains(t, tr, "a", "b") 37 | assertDirContains(t, tr, "a", "bb") 38 | 39 | require.NoError(t, tr.AddDir("c/d")) 40 | assertDir(t, tr, "c") 41 | assertDir(t, tr, "c/d") 42 | assertDirContains(t, tr, "", "c") 43 | assertDirContains(t, tr, "c", "d") 44 | 45 | require.NoError(t, tr.AddFile("a/f1", 10, nil)) 46 | assertFile(t, tr, "a/f1", 10) 47 | assertDirContains(t, tr, "a", "f1") 48 | 49 | require.NoError(t, tr.AddFile("e/f1", 10, nil)) 50 | assertFile(t, tr, "e/f1", 10) 51 | assertDir(t, tr, "e") 52 | assertDirContains(t, tr, "e", "f1") 53 | } 54 | 55 | func TestOpen(t *testing.T) { 56 | t.Parallel() 57 | tr := make(Tree) 58 | require.NoError(t, tr.AddFileContent("a", []byte("file a"))) 59 | require.NoError(t, tr.AddFileContent("b/c", []byte("file c"))) 60 | 61 | // Valid file paths. 62 | for _, path := range []string{"a", "/a"} { 63 | f, err := tr.Open(path) 64 | require.NoError(t, err) 65 | assertContent(t, f, "file a") 66 | st, err := f.Stat() 67 | require.NoError(t, err) 68 | assertFileInfo(t, st, "a", 6) 69 | } 70 | 71 | // Valid directory paths. 72 | for _, path := range []string{"b", "/b", "b/", "/b/"} { 73 | f, err := tr.Open(path) 74 | require.NoError(t, err) 75 | st, err := f.Stat() 76 | require.NoError(t, err) 77 | assertDirInfo(t, st, "b") 78 | } 79 | 80 | // Not found. 81 | _, err := tr.Open("nosuchfile") 82 | assert.EqualError(t, err, os.ErrNotExist.Error()) 83 | 84 | // Invalid - a is a file not a directory. 85 | _, err = tr.Open("a/") 86 | assert.EqualError(t, err, os.ErrInvalid.Error()) 87 | } 88 | 89 | func TestTree_empty(t *testing.T) { 90 | t.Parallel() 91 | 92 | tr := make(Tree) 93 | root, err := tr.Open("/") 94 | require.NoError(t, err) 95 | 96 | files, err := root.Readdir(0) 97 | require.NoError(t, err) 98 | 99 | assert.Len(t, files, 0) 100 | } 101 | 102 | func TestOpen_concurrent(t *testing.T) { 103 | t.Parallel() 104 | const ( 105 | goroutines = 10 106 | loops = 100 107 | ) 108 | 109 | tr := make(Tree) 110 | require.NoError(t, tr.AddFileContent("a", []byte("file a"))) 111 | var wg sync.WaitGroup 112 | 113 | wg.Add(goroutines) 114 | for i := 0; i < goroutines; i++ { 115 | go func() { 116 | defer wg.Done() 117 | for j := 0; j < loops; j++ { 118 | a, err := tr.Open("a") 119 | require.NoError(t, err) 120 | assertContent(t, a, "file a") 121 | } 122 | }() 123 | } 124 | wg.Wait() 125 | } 126 | 127 | func TestDir_readDir(t *testing.T) { 128 | t.Parallel() 129 | 130 | tr := make(Tree) 131 | require.NoError(t, tr.AddFile("a/1", 0, nil)) 132 | require.NoError(t, tr.AddFile("a/2", 0, nil)) 133 | require.NoError(t, tr.AddFile("a/3", 0, nil)) 134 | 135 | tests := []struct { 136 | count int 137 | wantLen int 138 | }{ 139 | {count: -1, wantLen: 3}, 140 | {count: 0, wantLen: 3}, 141 | {count: 1, wantLen: 1}, 142 | {count: 2, wantLen: 2}, 143 | {count: 3, wantLen: 3}, 144 | {count: 4, wantLen: 3}, 145 | } 146 | 147 | for _, tt := range tests { 148 | t.Run(fmt.Sprintf("%d", tt.count), func(t *testing.T) { 149 | files, err := tr["a"].Readdir(tt.count) 150 | require.NoError(t, err) 151 | assert.Len(t, files, tt.wantLen) 152 | }) 153 | } 154 | } 155 | 156 | func TestFile_read(t *testing.T) { 157 | t.Parallel() 158 | 159 | content := "content" 160 | 161 | tr := make(Tree) 162 | require.NoError(t, tr.AddFileContent("a", []byte(content))) 163 | 164 | assertContent(t, tr["a"].Open(), content) 165 | } 166 | 167 | func TestFile_readFailure(t *testing.T) { 168 | t.Parallel() 169 | 170 | tr := make(Tree) 171 | require.NoError(t, tr.AddFile("a", 10, func(context.Context) ([]byte, error) { return nil, fmt.Errorf("failed") })) 172 | assert.NotNil(t, tr["a"]) 173 | 174 | buf := make([]byte, 10) 175 | _, err := tr["a"].Open().Read(buf) 176 | assert.Error(t, err) 177 | } 178 | 179 | func TestFile_overrideFailure(t *testing.T) { 180 | t.Parallel() 181 | 182 | tr := make(Tree) 183 | assert.NoError(t, tr.AddFile("a", 10, nil)) 184 | assert.Error(t, tr.AddDir("a")) 185 | 186 | assert.NoError(t, tr.AddDir("b")) 187 | assert.Error(t, tr.AddFile("b", 10, nil)) 188 | } 189 | 190 | func assertDir(t *testing.T, tr Tree, path string) { 191 | t.Helper() 192 | d := tr[path] 193 | require.NotNil(t, d) 194 | st, err := d.Stat() 195 | require.NoError(t, err) 196 | assertDirInfo(t, st, filepath.Base(path)) 197 | } 198 | 199 | func assertDirInfo(t *testing.T, st os.FileInfo, name string) { 200 | t.Helper() 201 | assert.True(t, st.IsDir()) 202 | assert.Equal(t, name, st.Name()) 203 | assert.Equal(t, int64(0), st.Size()) 204 | assert.Equal(t, os.ModeDir, st.Mode()) 205 | assert.Equal(t, time.Time{}, st.ModTime()) 206 | assert.Nil(t, nil, st.Sys()) 207 | } 208 | 209 | func assertDirContains(t *testing.T, tr Tree, path string, contains string) { 210 | t.Helper() 211 | require.NotNil(t, tr[path]) 212 | files, err := tr[path].Readdir(-1) 213 | require.NoError(t, err) 214 | for _, f := range files { 215 | if f.Name() == contains { 216 | return 217 | } 218 | } 219 | t.Errorf("Dir %q did not contain file %q", path, contains) 220 | } 221 | 222 | func assertFile(t *testing.T, tr Tree, path string, size int64) { 223 | t.Helper() 224 | require.NotNil(t, tr[path]) 225 | st, err := tr[path].Stat() 226 | require.NoError(t, err) 227 | assertFileInfo(t, st, filepath.Base(path), size) 228 | } 229 | 230 | func assertFileInfo(t *testing.T, st os.FileInfo, name string, size int64) { 231 | t.Helper() 232 | assert.False(t, st.IsDir()) 233 | assert.Equal(t, st.Size(), size) 234 | assert.Equal(t, name, st.Name()) 235 | assert.Equal(t, os.FileMode(0), st.Mode()) 236 | assert.Equal(t, time.Time{}, st.ModTime()) 237 | assert.Nil(t, nil, st.Sys()) 238 | } 239 | 240 | func assertContent(t *testing.T, r io.Reader, content string) { 241 | t.Helper() 242 | require.NotNil(t, r) 243 | b, err := ioutil.ReadAll(r) 244 | require.NoError(t, err) 245 | assert.Equal(t, content, string(b)) 246 | } 247 | --------------------------------------------------------------------------------