From cd5338f303daa1cffdc29c96a6e42d557bf82178 Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 15:43:34 +0000 Subject: [PATCH] =?UTF-8?q?perf(attachment):=20=E4=B8=8A=E4=BC=A0=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E5=8C=96=EF=BC=88io.Pipe=20=E9=9B=B6=E5=86=85?= =?UTF-8?q?=E5=AD=98=E7=BC=93=E5=86=B2=EF=BC=89+=20=E5=A4=A7=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=BF=9B=E5=BA=A6=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/client/upload.go | 105 +++++++++++++++++++++++++++------ internal/client/upload_test.go | 55 +++++++++++++++++ 2 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 internal/client/upload_test.go diff --git a/internal/client/upload.go b/internal/client/upload.go index fb41f326..344a64de 100644 --- a/internal/client/upload.go +++ b/internal/client/upload.go @@ -1,7 +1,6 @@ package client import ( - "bytes" "encoding/json" "fmt" "io" @@ -14,6 +13,63 @@ import ( "github.com/gitlink-org/gitlink-cli/internal/output" ) +// progressThreshold is the minimum file size for which upload progress is +// reported on stderr. +const progressThreshold = 1 << 20 // 1 MiB + +// progressReporter prints upload progress to stderr at 10% steps for files +// larger than progressThreshold. It implements io.Writer so it can sit on +// the tee side of the upload stream. +type progressReporter struct { + name string + total int64 + done int64 + lastPct int64 + lastLine int + out io.Writer +} + +func newProgressReporter(name string, total int64) *progressReporter { + return &progressReporter{name: name, total: total, lastPct: -1, out: os.Stderr} +} + +func newProgressReporterTo(name string, total int64, out io.Writer) *progressReporter { + return &progressReporter{name: name, total: total, lastPct: -1, out: out} +} + +func (p *progressReporter) Write(b []byte) (int, error) { + p.done += int64(len(b)) + if p.total >= progressThreshold { + pct := p.done * 100 / p.total + if pct/10 > p.lastPct/10 || (pct == 100 && p.lastPct != 100) { + line := fmt.Sprintf("uploading %s: %d%% (%s / %s)", p.name, pct, formatBytes(p.done), formatBytes(p.total)) + if pad := p.lastLine - len(line); pad > 0 { + line += strings.Repeat(" ", pad) + } + fmt.Fprintf(p.out, "\r%s", line) + p.lastLine = len(line) + if pct >= 100 { + fmt.Fprintln(p.out) + } + p.lastPct = pct + } + } + return len(b), nil +} + +func formatBytes(n int64) string { + switch { + case n >= 1<<30: + return fmt.Sprintf("%.1f GiB", float64(n)/(1<<30)) + case n >= 1<<20: + return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%d B", n) + } +} + // PostMultipartFile uploads a local file as a multipart/form-data request. // fileField is the form field name for the file (GitLink expects "file"); // extra fields (e.g. description) are added as plain form values. @@ -24,28 +80,39 @@ func (c *Client) PostMultipartFile(path, filePath, fileField string, fields map[ } defer f.Close() - var buf bytes.Buffer - writer := multipart.NewWriter(&buf) - part, err := writer.CreateFormFile(fileField, filepath.Base(filePath)) + info, err := f.Stat() if err != nil { - return nil, err - } - if _, err := io.Copy(part, f); err != nil { - return nil, fmt.Errorf("read upload file: %w", err) - } - for k, v := range fields { - if v != "" { - if err := writer.WriteField(k, v); err != nil { - return nil, err - } - } - } - if err := writer.Close(); err != nil { - return nil, err + return nil, fmt.Errorf("stat upload file: %w", err) } + // Stream the multipart body through a pipe so arbitrarily large files + // are never buffered in memory. + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + progress := newProgressReporter(filepath.Base(filePath), info.Size()) + go func() { + part, err := writer.CreateFormFile(fileField, filepath.Base(filePath)) + if err != nil { + pw.CloseWithError(err) + return + } + if _, err := io.Copy(part, io.TeeReader(f, progress)); err != nil { + pw.CloseWithError(fmt.Errorf("read upload file: %w", err)) + return + } + for k, v := range fields { + if v != "" { + if err := writer.WriteField(k, v); err != nil { + pw.CloseWithError(err) + return + } + } + } + pw.CloseWithError(writer.Close()) + }() + fullURL := c.BaseURL + normalizeAPIPath(c.BaseURL, path) - req, err := http.NewRequest("POST", fullURL, &buf) + req, err := http.NewRequest("POST", fullURL, pr) if err != nil { return nil, err } diff --git a/internal/client/upload_test.go b/internal/client/upload_test.go new file mode 100644 index 00000000..6dc09233 --- /dev/null +++ b/internal/client/upload_test.go @@ -0,0 +1,55 @@ +package client + +import ( + "bytes" + "strings" + "testing" +) + +func TestProgressReporterLargeFile(t *testing.T) { + var buf bytes.Buffer + total := int64(4 << 20) + p := newProgressReporterTo("big.bin", total, &buf) + + chunk := make([]byte, 1<<20) + for i := 0; i < 4; i++ { + if _, err := p.Write(chunk); err != nil { + t.Fatal(err) + } + } + out := buf.String() + if !strings.Contains(out, "uploading big.bin") { + t.Fatalf("missing progress prefix: %q", out) + } + if !strings.Contains(out, "100%") { + t.Fatalf("missing 100%% mark: %q", out) + } + if !strings.Contains(out, "4.0 MiB / 4.0 MiB") { + t.Fatalf("missing byte summary: %q", out) + } +} + +func TestProgressReporterSmallFileSilent(t *testing.T) { + var buf bytes.Buffer + p := newProgressReporterTo("small.txt", 1024, &buf) + if _, err := p.Write(make([]byte, 1024)); err != nil { + t.Fatal(err) + } + if buf.Len() != 0 { + t.Fatalf("expected no progress output for small file, got %q", buf.String()) + } +} + +func TestFormatBytes(t *testing.T) { + cases := map[int64]string{ + 512: "512 B", + 2 << 10: "2.0 KiB", + 3 << 20: "3.0 MiB", + 5 << 30: "5.0 GiB", + } + for in, want := range cases { + if got := formatBytes(in); got != want { + t.Fatalf("formatBytes(%d) = %q, want %q", in, got, want) + } + } +}