tangled
alpha
login
or
join now
whitequark.org
/
git-pages-cli
1
fork
atom
[mirror] Command-line application for uploading a site to a git-pages server
1
fork
atom
overview
issues
pulls
pipelines
Add incremental directory upload support.
whitequark.org
3 months ago
91c96b3f
0e99ddaa
+113
-60
1 changed file
expand all
collapse all
unified
split
main.go
+113
-60
main.go
···
2
3
import (
4
"archive/tar"
0
5
"bytes"
0
6
"crypto/sha256"
0
7
"errors"
8
"fmt"
9
"io"
···
12
"net/url"
13
"os"
14
"runtime/debug"
0
15
"strings"
16
17
"github.com/google/uuid"
···
39
var challengeFlag = pflag.Bool("challenge", false, "compute DNS challenge entry from password (output zone file record)")
40
var challengeBareFlag = pflag.Bool("challenge-bare", false, "compute DNS challenge entry from password (output bare TXT value)")
41
var uploadGitFlag = pflag.String("upload-git", "", "replace site with contents of specified git repository")
42
-
var uploadDirFlag = pflag.String("upload-dir", "", "replace whole site or a subdirectory with contents of specified directory")
43
-
var deleteFlag = pflag.Bool("delete", false, "delete whole site or a subdirectory")
44
var debugManifestFlag = pflag.Bool("debug-manifest", false, "retrieve site manifest as ProtoJSON, for debugging")
45
var serverFlag = pflag.String("server", "", "hostname of server to connect to")
46
var pathFlag = pflag.String("path", "", "partially update site at specified path")
47
var parentsFlag = pflag.Bool("parents", false, "create parent directories of --path")
48
var atomicFlag = pflag.Bool("atomic", false, "require partial updates to be atomic")
0
49
var verboseFlag = pflag.BoolP("verbose", "v", false, "display more information for debugging")
50
var versionFlag = pflag.BoolP("version", "V", false, "display version information")
51
···
75
return operations == 1
76
}
77
0
0
0
0
0
0
0
0
0
78
func displayFS(root fs.FS, prefix string) error {
79
return fs.WalkDir(root, ".", func(name string, entry fs.DirEntry, err error) error {
80
if err != nil {
···
94
})
95
}
96
97
-
func archiveFS(writer io.Writer, root fs.FS, prefix string) (err error) {
0
0
0
0
0
0
0
0
98
zstdWriter, _ := zstd.NewWriter(writer)
99
tarWriter := tar.NewWriter(zstdWriter)
100
if err = fs.WalkDir(root, ".", func(name string, entry fs.DirEntry, err error) error {
101
if err != nil {
102
return err
103
}
104
-
fileInfo, err := entry.Info()
105
-
if err != nil {
106
-
return err
107
-
}
108
-
var tarName string
109
if prefix == "" && name == "." {
110
return nil
111
} else if name == "." {
112
-
tarName = prefix
113
} else {
114
-
tarName = prefix + name
115
}
116
-
var file io.ReadCloser
117
-
var linkTarget string
118
switch {
119
case entry.Type().IsDir():
120
-
name += "/"
0
121
case entry.Type().IsRegular():
122
-
if file, err = root.Open(name); err != nil {
0
123
return err
124
}
125
-
defer file.Close()
0
0
0
0
0
0
0
126
case entry.Type() == fs.ModeSymlink:
127
-
if linkTarget, err = fs.ReadLink(root, name); err != nil {
0
128
return err
129
}
130
default:
131
return errors.New("tar: cannot add non-regular file")
132
}
133
-
header, err := tar.FileInfoHeader(fileInfo, linkTarget)
134
-
if err != nil {
135
return err
136
}
137
-
header.Name = tarName
138
-
if err = tarWriter.WriteHeader(header); err != nil {
139
return err
140
}
141
-
if file != nil {
142
-
_, err = io.Copy(tarWriter, file)
143
-
}
144
return err
145
}); err != nil {
146
return
···
152
return
153
}
154
return
0
0
0
0
0
0
0
0
0
0
0
0
0
0
155
}
156
157
func makeWhiteout(path string) (reader io.Reader) {
···
203
}
204
}
205
0
0
0
0
0
206
var err error
207
siteURL, err := url.Parse(pflag.Args()[0])
208
if err != nil {
···
211
}
212
213
var request *http.Request
0
214
switch {
215
case *challengeFlag || *challengeBareFlag:
216
if *passwordFlag == "" {
···
242
request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
243
244
case *uploadDirFlag != "":
245
-
uploadDirFS, err := os.OpenRoot(*uploadDirFlag)
246
if err != nil {
247
fmt.Fprintf(os.Stderr, "error: invalid directory: %s\n", err)
248
os.Exit(1)
249
}
250
251
if *verboseFlag {
252
-
err := displayFS(uploadDirFS.FS(), pathPrefix)
253
if err != nil {
254
fmt.Fprintf(os.Stderr, "error: %s\n", err)
255
os.Exit(1)
256
}
257
}
258
259
-
// Stream archive data without ever loading the entire working set into RAM.
260
-
reader, writer := io.Pipe()
261
-
go func() {
262
-
err = archiveFS(writer, uploadDirFS.FS(), pathPrefix)
263
-
if err != nil {
264
-
fmt.Fprintf(os.Stderr, "error: %s\n", err)
265
-
os.Exit(1)
266
-
}
267
-
writer.Close()
268
-
}()
269
-
270
if *pathFlag == "" {
271
-
request, err = http.NewRequest("PUT", siteURL.String(), reader)
272
} else {
273
-
request, err = http.NewRequest("PATCH", siteURL.String(), reader)
274
}
275
if err != nil {
276
fmt.Fprintf(os.Stderr, "error: %s\n", err)
277
os.Exit(1)
278
}
0
279
request.ContentLength = -1
280
request.Header.Add("Content-Type", "application/x-tar+zstd")
0
281
if *parentsFlag {
282
request.Header.Add("Create-Parents", "yes")
283
} else {
···
338
request.Header.Set("Host", siteURL.Host)
339
}
340
341
-
response, err := http.DefaultClient.Do(request)
342
-
if err != nil {
343
-
fmt.Fprintf(os.Stderr, "error: %s\n", err)
344
-
os.Exit(1)
345
-
}
346
-
if *verboseFlag {
347
-
fmt.Fprintf(os.Stderr, "server: %s\n", response.Header.Get("Server"))
348
-
}
349
-
if *debugManifestFlag {
350
-
if response.StatusCode == 200 {
351
-
io.Copy(os.Stdout, response.Body)
352
-
fmt.Fprintf(os.Stdout, "\n")
353
-
} else {
354
-
io.Copy(os.Stderr, response.Body)
355
os.Exit(1)
356
}
357
-
} else { // an update operation
358
-
if response.StatusCode == 200 {
359
-
fmt.Fprintf(os.Stdout, "result: %s\n", response.Header.Get("Update-Result"))
360
-
io.Copy(os.Stdout, response.Body)
361
-
} else {
362
-
fmt.Fprintf(os.Stderr, "result: error\n")
363
-
io.Copy(os.Stderr, response.Body)
364
-
os.Exit(1)
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
365
}
0
366
}
367
}
···
2
3
import (
4
"archive/tar"
5
+
"bufio"
6
"bytes"
7
+
"crypto"
8
"crypto/sha256"
9
+
"encoding/hex"
10
"errors"
11
"fmt"
12
"io"
···
15
"net/url"
16
"os"
17
"runtime/debug"
18
+
"strconv"
19
"strings"
20
21
"github.com/google/uuid"
···
43
var challengeFlag = pflag.Bool("challenge", false, "compute DNS challenge entry from password (output zone file record)")
44
var challengeBareFlag = pflag.Bool("challenge-bare", false, "compute DNS challenge entry from password (output bare TXT value)")
45
var uploadGitFlag = pflag.String("upload-git", "", "replace site with contents of specified git repository")
46
+
var uploadDirFlag = pflag.String("upload-dir", "", "replace whole site or a path with contents of specified directory")
47
+
var deleteFlag = pflag.Bool("delete", false, "delete whole site or a path")
48
var debugManifestFlag = pflag.Bool("debug-manifest", false, "retrieve site manifest as ProtoJSON, for debugging")
49
var serverFlag = pflag.String("server", "", "hostname of server to connect to")
50
var pathFlag = pflag.String("path", "", "partially update site at specified path")
51
var parentsFlag = pflag.Bool("parents", false, "create parent directories of --path")
52
var atomicFlag = pflag.Bool("atomic", false, "require partial updates to be atomic")
53
+
var incrementalFlag = pflag.Bool("incremental", false, "only upload changed files")
54
var verboseFlag = pflag.BoolP("verbose", "v", false, "display more information for debugging")
55
var versionFlag = pflag.BoolP("version", "V", false, "display version information")
56
···
80
return operations == 1
81
}
82
83
+
func gitBlobSHA256(data []byte) string {
84
+
h := crypto.SHA256.New()
85
+
h.Write([]byte("blob "))
86
+
h.Write([]byte(strconv.FormatInt(int64(len(data)), 10)))
87
+
h.Write([]byte{0})
88
+
h.Write(data)
89
+
return hex.EncodeToString(h.Sum(nil))
90
+
}
91
+
92
func displayFS(root fs.FS, prefix string) error {
93
return fs.WalkDir(root, ".", func(name string, entry fs.DirEntry, err error) error {
94
if err != nil {
···
108
})
109
}
110
111
+
// It doesn't make sense to use incremental updates for very small files since the cost of
112
+
// repeating a request to fill in a missing blob is likely to be higher than any savings gained.
113
+
const incrementalSizeThreshold = 256
114
+
115
+
func archiveFS(writer io.Writer, root fs.FS, prefix string, needBlobs []string) (err error) {
116
+
requestedSet := make(map[string]struct{})
117
+
for _, hash := range needBlobs {
118
+
requestedSet[hash] = struct{}{}
119
+
}
120
zstdWriter, _ := zstd.NewWriter(writer)
121
tarWriter := tar.NewWriter(zstdWriter)
122
if err = fs.WalkDir(root, ".", func(name string, entry fs.DirEntry, err error) error {
123
if err != nil {
124
return err
125
}
126
+
header := &tar.Header{}
127
+
data := []byte{}
0
0
0
128
if prefix == "" && name == "." {
129
return nil
130
} else if name == "." {
131
+
header.Name = prefix
132
} else {
133
+
header.Name = prefix + name
134
}
0
0
135
switch {
136
case entry.Type().IsDir():
137
+
header.Typeflag = tar.TypeDir
138
+
header.Name += "/"
139
case entry.Type().IsRegular():
140
+
header.Typeflag = tar.TypeReg
141
+
if data, err = fs.ReadFile(root, name); err != nil {
142
return err
143
}
144
+
if *incrementalFlag && len(data) > incrementalSizeThreshold {
145
+
hash := gitBlobSHA256(data)
146
+
if _, requested := requestedSet[hash]; !requested {
147
+
header.Typeflag = tar.TypeSymlink
148
+
header.Linkname = "/git/blobs/" + hash
149
+
data = nil
150
+
}
151
+
}
152
case entry.Type() == fs.ModeSymlink:
153
+
header.Typeflag = tar.TypeSymlink
154
+
if header.Linkname, err = fs.ReadLink(root, name); err != nil {
155
return err
156
}
157
default:
158
return errors.New("tar: cannot add non-regular file")
159
}
160
+
header.Size = int64(len(data))
161
+
if err = tarWriter.WriteHeader(header); err != nil {
162
return err
163
}
164
+
if _, err = tarWriter.Write(data); err != nil {
0
165
return err
166
}
0
0
0
167
return err
168
}); err != nil {
169
return
···
175
return
176
}
177
return
178
+
}
179
+
180
+
// Stream archive data without ever loading the entire working set into RAM.
181
+
func streamArchiveFS(root fs.FS, prefix string, needBlobs []string) io.ReadCloser {
182
+
reader, writer := io.Pipe()
183
+
go func() {
184
+
err := archiveFS(writer, root, prefix, needBlobs)
185
+
if err != nil {
186
+
writer.CloseWithError(err)
187
+
} else {
188
+
writer.Close()
189
+
}
190
+
}()
191
+
return reader
192
}
193
194
func makeWhiteout(path string) (reader io.Reader) {
···
240
}
241
}
242
243
+
if *incrementalFlag && *uploadDirFlag == "" {
244
+
fmt.Fprintf(os.Stderr, "--incremental requires --upload-dir")
245
+
os.Exit(usageExitCode)
246
+
}
247
+
248
var err error
249
siteURL, err := url.Parse(pflag.Args()[0])
250
if err != nil {
···
253
}
254
255
var request *http.Request
256
+
var uploadDir *os.Root
257
switch {
258
case *challengeFlag || *challengeBareFlag:
259
if *passwordFlag == "" {
···
285
request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
286
287
case *uploadDirFlag != "":
288
+
uploadDir, err = os.OpenRoot(*uploadDirFlag)
289
if err != nil {
290
fmt.Fprintf(os.Stderr, "error: invalid directory: %s\n", err)
291
os.Exit(1)
292
}
293
294
if *verboseFlag {
295
+
err := displayFS(uploadDir.FS(), pathPrefix)
296
if err != nil {
297
fmt.Fprintf(os.Stderr, "error: %s\n", err)
298
os.Exit(1)
299
}
300
}
301
0
0
0
0
0
0
0
0
0
0
0
302
if *pathFlag == "" {
303
+
request, err = http.NewRequest("PUT", siteURL.String(), nil)
304
} else {
305
+
request, err = http.NewRequest("PATCH", siteURL.String(), nil)
306
}
307
if err != nil {
308
fmt.Fprintf(os.Stderr, "error: %s\n", err)
309
os.Exit(1)
310
}
311
+
request.Body = streamArchiveFS(uploadDir.FS(), pathPrefix, []string{})
312
request.ContentLength = -1
313
request.Header.Add("Content-Type", "application/x-tar+zstd")
314
+
request.Header.Add("Accept", "application/vnd.git-pages.unresolved;q=1.0, text/plain;q=0.9")
315
if *parentsFlag {
316
request.Header.Add("Create-Parents", "yes")
317
} else {
···
372
request.Header.Set("Host", siteURL.Host)
373
}
374
375
+
displayServer := *verboseFlag
376
+
for {
377
+
response, err := http.DefaultClient.Do(request)
378
+
if err != nil {
379
+
fmt.Fprintf(os.Stderr, "error: %s\n", err)
0
0
0
0
0
0
0
0
0
380
os.Exit(1)
381
}
382
+
if displayServer {
383
+
fmt.Fprintf(os.Stderr, "server: %s\n", response.Header.Get("Server"))
384
+
displayServer = false
385
+
}
386
+
if *debugManifestFlag {
387
+
if response.StatusCode == http.StatusOK {
388
+
io.Copy(os.Stdout, response.Body)
389
+
fmt.Fprintf(os.Stdout, "\n")
390
+
} else {
391
+
io.Copy(os.Stderr, response.Body)
392
+
os.Exit(1)
393
+
}
394
+
} else { // an update operation
395
+
if *verboseFlag {
396
+
fmt.Fprintf(os.Stderr, "response: %d %s\n",
397
+
response.StatusCode, response.Header.Get("Content-Type"))
398
+
}
399
+
if response.StatusCode == http.StatusUnprocessableEntity &&
400
+
response.Header.Get("Content-Type") == "application/vnd.git-pages.unresolved" {
401
+
needBlobs := []string{}
402
+
scanner := bufio.NewScanner(response.Body)
403
+
for scanner.Scan() {
404
+
needBlobs = append(needBlobs, scanner.Text())
405
+
}
406
+
response.Body.Close()
407
+
request.Body = streamArchiveFS(uploadDir.FS(), pathPrefix, needBlobs)
408
+
continue // resubmit
409
+
} else if response.StatusCode == http.StatusOK {
410
+
fmt.Fprintf(os.Stdout, "result: %s\n", response.Header.Get("Update-Result"))
411
+
io.Copy(os.Stdout, response.Body)
412
+
} else {
413
+
fmt.Fprintf(os.Stderr, "result: error\n")
414
+
io.Copy(os.Stderr, response.Body)
415
+
os.Exit(1)
416
+
}
417
}
418
+
break
419
}
420
}