From 31e7b72b4921c054ea31cd221094e96e3774495c Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Wed, 3 Jun 2026 01:21:55 +0800 Subject: [PATCH] feat(cli): add BuildSourceArchive for plugin publish tar.zst --- cmd/ninja/internal/archive/archive.go | 41 ++++++++++ cmd/ninja/internal/archive/archive_test.go | 89 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 cmd/ninja/internal/archive/archive.go create mode 100644 cmd/ninja/internal/archive/archive_test.go diff --git a/cmd/ninja/internal/archive/archive.go b/cmd/ninja/internal/archive/archive.go new file mode 100644 index 0000000..158a5ac --- /dev/null +++ b/cmd/ninja/internal/archive/archive.go @@ -0,0 +1,41 @@ +package archive + +import ( + "bytes" + "fmt" + "io" + "os/exec" + + "github.com/klauspost/compress/zstd" +) + +// BuildSourceArchive runs `git archive --format=tar HEAD` in repoDir and +// compresses the result with zstd. Returns the compressed bytes. +// +// Only tracked files at HEAD are included. .gitignored files that were +// never tracked are excluded. Tracked-then-gitignored files are still +// included — callers may warn separately. +func BuildSourceArchive(repoDir string) ([]byte, error) { + cmd := exec.Command("git", "archive", "--format=tar", "HEAD") + cmd.Dir = repoDir + var tarOut, stderr bytes.Buffer + cmd.Stdout = &tarOut + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("git archive: %v: %s", err, stderr.String()) + } + + var compressed bytes.Buffer + enc, err := zstd.NewWriter(&compressed, zstd.WithEncoderLevel(zstd.SpeedDefault)) + if err != nil { + return nil, err + } + if _, err := io.Copy(enc, &tarOut); err != nil { + _ = enc.Close() + return nil, err + } + if err := enc.Close(); err != nil { + return nil, err + } + return compressed.Bytes(), nil +} diff --git a/cmd/ninja/internal/archive/archive_test.go b/cmd/ninja/internal/archive/archive_test.go new file mode 100644 index 0000000..08dbdd5 --- /dev/null +++ b/cmd/ninja/internal/archive/archive_test.go @@ -0,0 +1,89 @@ +package archive + +import ( + "archive/tar" + "bytes" + "io" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/klauspost/compress/zstd" +) + +func TestBuildSourceArchive_RoundTrip(t *testing.T) { + dir := t.TempDir() + run := func(name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", + "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", + "GIT_COMMITTER_EMAIL=t@t", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %v: %v\n%s", name, args, err, out) + } + } + run("git", "init", "-q") + if err := os.WriteFile(filepath.Join(dir, "plugin.mod"), + []byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "ignored.log"), []byte("nope"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored.log\n"), 0o644); err != nil { + t.Fatal(err) + } + run("git", "add", "plugin.mod", ".gitignore") + run("git", "commit", "-qm", "init") + + zstdBytes, err := BuildSourceArchive(dir) + if err != nil { + t.Fatalf("BuildSourceArchive: %v", err) + } + if len(zstdBytes) == 0 { + t.Fatal("empty archive") + } + + dec, err := zstd.NewReader(bytes.NewReader(zstdBytes)) + if err != nil { + t.Fatal(err) + } + defer dec.Close() + tr := tar.NewReader(dec) + got := map[string]string{} + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + buf, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + got[hdr.Name] = string(buf) + } + if _, ok := got["plugin.mod"]; !ok { + t.Errorf("expected plugin.mod in archive, got %v", keys(got)) + } + if _, ok := got["ignored.log"]; ok { + t.Errorf("ignored.log should not be in archive (gitignored + untracked)") + } +} + +func keys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +}