feat: publish builds the .bnp itself — the artifact is the only publish form

ninja plugin publish now runs the build pipeline (codeless or wasm,
same as `ninja plugin build`) and uploads the resulting .bnp; the
legacy source-archive path is deleted (internal/archive removed).
--bnp remains as an optional prebuilt-artifact override.

Client-side guard mirrors the orchestrator verify gate: a non-empty
manifest version that differs from plugin.mod fails before upload.

The git-archive-era publish warnings (gitignored-tracked, untracked,
submodules) described a ship mechanism that no longer exists; replaced
with a single dirty-tree drift warning (--strict still aborts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-05 02:16:56 +08:00
parent 2f065e3ed4
commit feeb31139d
4 changed files with 81 additions and 561 deletions

View File

@ -17,7 +17,7 @@ import (
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/cli/internal/archive"
"git.dev.alexdunmow.com/block/cli/internal/bnp"
"git.dev.alexdunmow.com/block/cli/internal/creds"
"git.dev.alexdunmow.com/block/cli/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/cli/internal/api/orchestrator/v1"
@ -682,7 +682,7 @@ func newPluginPublishCmd() *cobra.Command {
var bnpFile string
cmd := &cobra.Command{
Use: "publish",
Short: "Publish a new version to the registry (source archive, or a prebuilt .bnp via --bnp)",
Short: "Build the .bnp artifact and publish it to the registry",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
@ -752,26 +752,43 @@ func newPluginPublishCmd() *cobra.Command {
return err
}
// WO-WZ-011: --bnp <file> uploads a prebuilt wasm artifact (from
// `ninja plugin build`) instead of a source archive. The orchestrator
// verifies the .bnp layout/ABI/name/version server-side and records
// its abi_version. Without --bnp, publish keeps shipping a source
// archive (compiled in-container by the CMS reconciler) for
// backwards compatibility until the CMS install path is wasm-native.
var (
archiveBytes []byte
artifactKind = "source archive"
)
// A .bnp artifact is the ONLY thing publish ships — instances
// reject source archives at install time, so the legacy
// source-archive path is gone. Default: build the artifact right
// here (same pipeline as `ninja plugin build`). --bnp skips the
// build and uploads a prebuilt artifact instead. The orchestrator
// verifies the .bnp layout/ABI/name/version server-side and
// records its abi_version.
var archiveBytes []byte
if bnpFile != "" {
archiveBytes, err = os.ReadFile(bnpFile)
if err != nil {
return fmt.Errorf("read --bnp file: %w", err)
}
artifactKind = ".bnp artifact"
} else {
archiveBytes, err = archive.BuildSourceArchive(".")
isCodeless, err := bnp.IsCodelessRepo(".")
if err != nil {
return fmt.Errorf("build archive: %w", err)
return err
}
var res *bnp.BuildResult
if isCodeless {
res, err = bnp.BuildCodeless(context.Background(), bnp.BuildOptions{Dir: "."})
} else {
res, err = bnp.Build(context.Background(), bnp.BuildOptions{Dir: "."})
}
if err != nil {
return fmt.Errorf("build .bnp: %w", err)
}
printBuildSummary(res)
// Mirror the orchestrator's verify gate (manifest version must
// match the published version when set) so the drift fails
// here, before the upload, with a fixable message.
if res.Version != "" && res.Version != mod.Plugin.Version {
return fmt.Errorf("manifest version %s != plugin.mod version %s (sync Registration.Version with plugin.mod)", res.Version, mod.Plugin.Version)
}
archiveBytes, err = os.ReadFile(res.OutputPath)
if err != nil {
return fmt.Errorf("read built artifact: %w", err)
}
}
@ -803,7 +820,7 @@ func newPluginPublishCmd() *cobra.Command {
if err != nil {
return fmt.Errorf("publish: %w", err)
}
fmt.Printf("Published %s (%s, %d bytes)\n", mod.Coords(), artifactKind, len(archiveBytes))
fmt.Printf("Published %s (.bnp artifact, %d bytes)\n", mod.Coords(), len(archiveBytes))
for _, w := range pubResp.Msg.Warnings {
fmt.Printf(" warning: %s\n", w)
}
@ -811,9 +828,9 @@ func newPluginPublishCmd() *cobra.Command {
},
}
cmd.Flags().StringVar(&channel, "channel", "latest", "Channel to point at this version")
cmd.Flags().BoolVar(&strict, "strict", false, "Fail if the working tree is dirty (default: ship dirty trees via stash-create)")
cmd.Flags().BoolVar(&strict, "strict", false, "Fail if the working tree is dirty (default: warn and build from the working directory)")
cmd.Flags().BoolVar(&privateFlag, "private", false, "Publish as a private plugin under your active account's @private namespace")
cmd.Flags().StringVar(&bnpFile, "bnp", "", "Upload a prebuilt .bnp wasm artifact (from `ninja plugin build`) instead of a source archive")
cmd.Flags().StringVar(&bnpFile, "bnp", "", "Upload a prebuilt .bnp artifact (from `ninja plugin build`) instead of building one")
return cmd
}
@ -1028,9 +1045,8 @@ func runCmd(name string, args ...string) error {
// checkRepoHasHEAD returns a friendlier error than the raw git failure when
// the publish flow is invoked in a freshly-initialised repository that has
// no commits yet. Without this, `git stash create` reports "You do not have
// the initial commit yet" via exit status 128, which surfaces as an internal
// failure to the user.
// no commits yet (the auto-commit of plugin.mod and the dirty-tree check
// both need a HEAD to diff against).
func checkRepoHasHEAD(repoDir string) error {
cmd := exec.Command("git", "rev-parse", "--verify", "HEAD")
cmd.Dir = repoDir
@ -1040,27 +1056,6 @@ func checkRepoHasHEAD(repoDir string) error {
return nil
}
// submodulePaths returns the configured submodule paths from .gitmodules.
// Reading the file directly (rather than running `git submodule status`) means
// we detect submodules that have been declared but not yet initialised.
func submodulePaths(repoDir string) []string {
cmd := exec.Command("git", "config", "--file", ".gitmodules", "--get-regexp", `submodule\..*\.path`)
cmd.Dir = repoDir
out, err := cmd.Output()
if err != nil {
return nil
}
var paths []string
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
// each line is "submodule.<name>.path <path>"
fields := strings.Fields(line)
if len(fields) >= 2 {
paths = append(paths, fields[len(fields)-1])
}
}
return paths
}
func upsertPluginMod(scope, name, displayName, description, kind string, categories, tags []string, private bool) error {
const file = "plugin.mod"
existing, _ := os.ReadFile(file)
@ -1129,90 +1124,33 @@ func writeMod(path string, m *core.ModFile) error {
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// gitignoredTrackedWarning writes a warning to w if any tracked files
// in repoDir match the active .gitignore — those files still ship in the
// archive, which is almost always not what the author wants.
func gitignoredTrackedWarning(repoDir string, w io.Writer) {
cmd := exec.Command("git", "ls-files", "--cached", "--ignored", "--exclude-standard")
cmd.Dir = repoDir
out, _ := cmd.Output()
names := strings.TrimSpace(string(out))
if names == "" {
return
}
_, _ = fmt.Fprintln(w, "warning: these tracked files match .gitignore and will still be shipped:")
for n := range strings.SplitSeq(names, "\n") {
_, _ = fmt.Fprintln(w, " "+n)
}
_, _ = fmt.Fprintln(w, " (run `git rm --cached <file>` to drop)")
}
// untrackedFilesWarning writes a warning to w listing untracked files in
// repoDir. Fires on the dirty-tree publish path because `git stash create`
// only captures tracked content, so untracked files silently vanish from the
// archive.
func untrackedFilesWarning(repoDir string, w io.Writer) {
cmd := exec.Command("git", "ls-files", "--others", "--exclude-standard")
cmd.Dir = repoDir
out, _ := cmd.Output()
names := strings.TrimSpace(string(out))
if names == "" {
return
}
_, _ = fmt.Fprintln(w, "warning: these untracked files will NOT be in the archive:")
for n := range strings.SplitSeq(names, "\n") {
_, _ = fmt.Fprintln(w, " "+n)
}
_, _ = fmt.Fprintln(w, " (run `git add <file>` if they should be shipped)")
}
// emitPublishWarnings runs the publish-time warning helpers in the order the
// CLI expects:
// emitPublishWarnings gates the publish on working-tree state. The .bnp is
// built from the working directory while the version tag and registry
// metadata point at commits — a dirty tree means the published artifact may
// not match the tagged source.
//
// 1. gitignoredTrackedWarning is unconditional so the developer sees it even
// when the publish is about to abort under --strict.
// 2. If allowDirty is false (i.e. --strict) the working tree must be clean —
// a dirty tree returns an error and the remaining warnings (which are
// only useful on the proceeding-publish path) are skipped.
// 3. If allowDirty is true (the default) the untracked-files warning fires
// so the user knows those files won't be in the archive.
// 4. submoduleWarning is unconditional on the proceeding path because
// `git archive` does not recurse into submodules.
// - allowDirty=false (--strict): a dirty tree aborts the publish.
// - allowDirty=true (the default): a dirty tree emits a drift warning
// listing the changed/untracked paths, then the publish proceeds.
func emitPublishWarnings(repoDir string, allowDirty bool, w io.Writer) error {
gitignoredTrackedWarning(repoDir, w)
if !allowDirty {
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = repoDir
out, _ := cmd.Output()
if len(strings.TrimSpace(string(out))) > 0 {
dirty := strings.TrimSpace(string(out))
if dirty == "" {
return nil
}
if !allowDirty {
return fmt.Errorf("working tree dirty (--strict); commit your changes or drop --strict")
}
} else {
// `git stash create` only captures tracked content, so untracked
// files would be silently dropped from the archive. Warn loudly.
untrackedFilesWarning(repoDir, w)
_, _ = fmt.Fprintln(w, "warning: working tree differs from HEAD; the published .bnp is built from the working directory:")
for n := range strings.SplitSeq(dirty, "\n") {
_, _ = fmt.Fprintln(w, " "+n)
}
submoduleWarning(repoDir, w)
_, _ = fmt.Fprintln(w, " (commit before publishing so the artifact matches the tagged source)")
return nil
}
// submoduleWarning writes a warning to w if repoDir contains submodules.
// `git archive` doesn't recurse into them so they ship as empty directories;
// detection is via .gitmodules so it fires even for uninitialised submodules.
func submoduleWarning(repoDir string, w io.Writer) {
paths := submodulePaths(repoDir)
if len(paths) == 0 {
return
}
_, _ = fmt.Fprintln(w, "warning: this repo has submodules; git archive will ship them as empty directories:")
for _, p := range paths {
_, _ = fmt.Fprintln(w, " "+p)
}
_, _ = fmt.Fprintln(w, " (vendor the contents or pack them separately if the plugin depends on them)")
}
// autoCommitPluginMod stages and commits plugin.mod with the given message,
// if plugin.mod differs from HEAD. No-op when there's nothing to commit.
//

View File

@ -252,36 +252,7 @@ func gitHeadSHA(t *testing.T, dir string) string {
return strings.TrimSpace(string(out))
}
func TestGitignoredTrackedWarning_FiresWhenTrackedFileMatchesGitignore(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "secret.env")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitignore")
runGit(t, dir, "commit", "-qm", "ignore")
var buf bytes.Buffer
gitignoredTrackedWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "secret.env") {
t.Errorf("warning should list secret.env, got: %q", out)
}
if !strings.Contains(out, "git rm --cached") {
t.Errorf("warning should suggest `git rm --cached`, got: %q", out)
}
if !strings.HasSuffix(out, "\n") {
t.Errorf("warning should end with a newline (Fprintln), got: %q", out)
}
}
func TestGitignoredTrackedWarning_NoopWhenNothingMatches(t *testing.T) {
func TestEmitPublishWarnings_CleanTreeIsSilent(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
@ -290,150 +261,18 @@ func TestGitignoredTrackedWarning_NoopWhenNothingMatches(t *testing.T) {
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
for _, allowDirty := range []bool{true, false} {
var buf bytes.Buffer
gitignoredTrackedWarning(dir, &buf)
if err := emitPublishWarnings(dir, allowDirty, &buf); err != nil {
t.Fatalf("emitPublishWarnings(allowDirty=%t): %v", allowDirty, err)
}
if buf.Len() != 0 {
t.Errorf("expected empty output for clean repo, got: %q", buf.String())
t.Errorf("expected empty output for clean tree (allowDirty=%t), got: %q", allowDirty, buf.String())
}
}
}
func TestUntrackedFilesWarning_FiresWithUntrackedFile(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("scratch"), 0o644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
untrackedFilesWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "notes.txt") {
t.Errorf("warning should list notes.txt, got: %q", out)
}
if !strings.Contains(out, "git add") {
t.Errorf("warning should suggest `git add`, got: %q", out)
}
}
func TestUntrackedFilesWarning_NoopWithNoUntracked(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
var buf bytes.Buffer
untrackedFilesWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output, got: %q", buf.String())
}
}
func TestSubmoduleWarning_FiresWithGitmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
gitmodules := `[submodule "vendor/foo"]
path = vendor/foo
url = https://example.com/foo.git
`
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
submoduleWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "vendor/foo") {
t.Errorf("warning should mention vendor/foo, got: %q", out)
}
if !strings.Contains(out, "submodules") {
t.Errorf("warning should mention submodules, got: %q", out)
}
}
func TestSubmoduleWarning_NoopWithoutGitmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
var buf bytes.Buffer
submoduleWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output, got: %q", buf.String())
}
}
func TestEmitPublishWarnings_WarnsAboutGitignoreTracked(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "secret.env")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitignore")
runGit(t, dir, "commit", "-qm", "ignore")
var buf bytes.Buffer
if err := emitPublishWarnings(dir, false, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "secret.env") {
t.Errorf("expected gitignored-tracked warning to mention secret.env, got: %q", out)
}
if !strings.Contains(out, "match .gitignore") {
t.Errorf("expected gitignored-tracked warning fragment, got: %q", out)
}
}
func TestEmitPublishWarnings_WarnsAboutSubmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
gitmodules := `[submodule "vendor/foo"]
path = vendor/foo
url = https://example.com/foo.git
`
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitmodules")
runGit(t, dir, "commit", "-qm", "add submodule decl")
var buf bytes.Buffer
if err := emitPublishWarnings(dir, false, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "vendor/foo") {
t.Errorf("expected submodule warning to mention vendor/foo, got: %q", out)
}
if !strings.Contains(out, "submodules") {
t.Errorf("expected submodule warning fragment, got: %q", out)
}
}
func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) {
func TestEmitPublishWarnings_DirtyTree(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
@ -441,25 +280,32 @@ func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) {
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
// One untracked and one modified-tracked path — both must surface.
if err := os.WriteFile(filepath.Join(dir, "scratch.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("changed"), 0o644); err != nil {
t.Fatal(err)
}
t.Run("allowDirty=true surfaces untracked warning", func(t *testing.T) {
t.Run("allowDirty=true emits drift warning and proceeds", func(t *testing.T) {
var buf bytes.Buffer
if err := emitPublishWarnings(dir, true, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "scratch.txt") {
t.Errorf("expected untracked-files warning to mention scratch.txt, got: %q", out)
t.Errorf("expected drift warning to mention untracked scratch.txt, got: %q", out)
}
if !strings.Contains(out, "NOT be in the archive") {
t.Errorf("expected untracked-files warning fragment, got: %q", out)
if !strings.Contains(out, "README.md") {
t.Errorf("expected drift warning to mention modified README.md, got: %q", out)
}
if !strings.Contains(out, "built from the working directory") {
t.Errorf("expected drift warning fragment, got: %q", out)
}
})
t.Run("allowDirty=false aborts before untracked warning", func(t *testing.T) {
t.Run("allowDirty=false aborts", func(t *testing.T) {
var buf bytes.Buffer
err := emitPublishWarnings(dir, false, &buf)
if err == nil {
@ -471,8 +317,8 @@ func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) {
if !strings.Contains(err.Error(), "--strict") {
t.Errorf("expected dirty-tree error to reference --strict, got: %v", err)
}
if strings.Contains(buf.String(), "untracked files will NOT be in the archive") {
t.Errorf("untracked-files warning should not fire on dirty-abort path, got: %q", buf.String())
if buf.Len() != 0 {
t.Errorf("no warning should be written on the abort path, got: %q", buf.String())
}
})
}

View File

@ -1,56 +0,0 @@
package archive
import (
"bytes"
"fmt"
"io"
"os/exec"
"strings"
"github.com/klauspost/compress/zstd"
)
// BuildSourceArchive captures the working tree as `tar.zst` bytes.
//
// When the working tree is clean it archives HEAD. When it's dirty
// (modified or staged tracked files), it archives a temporary stash
// object so the dirty state is what ships — callers that want
// HEAD-only behaviour should reject dirty trees before calling.
// Untracked files are never included regardless of state.
func BuildSourceArchive(repoDir string) ([]byte, error) {
stashCmd := exec.Command("git", "stash", "create")
stashCmd.Dir = repoDir
var stashOut, stashErr bytes.Buffer
stashCmd.Stdout = &stashOut
stashCmd.Stderr = &stashErr
if err := stashCmd.Run(); err != nil {
return nil, fmt.Errorf("git stash create: %v: %s", err, stashErr.String())
}
treeish := "HEAD"
if sha := strings.TrimSpace(stashOut.String()); sha != "" {
treeish = sha
}
cmd := exec.Command("git", "archive", "--format=tar", treeish)
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
}

View File

@ -1,208 +0,0 @@
package archive
import (
"archive/tar"
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"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
}
func TestBuildSourceArchive_DirtyTreeShipsWorkingCopy(t *testing.T) {
dir := t.TempDir()
runGitArchive(t, dir, "init", "-q")
modPath := filepath.Join(dir, "plugin.mod")
if err := os.WriteFile(modPath,
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
runGitArchive(t, dir, "add", "plugin.mod")
runGitArchive(t, dir, "commit", "-qm", "init")
dirtyContents := []byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n")
if err := os.WriteFile(modPath, dirtyContents, 0o644); err != nil {
t.Fatal(err)
}
zstdBytes, err := BuildSourceArchive(dir)
if err != nil {
t.Fatalf("BuildSourceArchive: %v", err)
}
got := readArchive(t, zstdBytes)
contents, ok := got["plugin.mod"]
if !ok {
t.Fatalf("expected plugin.mod in archive, got %v", keys(got))
}
if !strings.Contains(contents, `version="0.1.1"`) {
t.Errorf("archived plugin.mod should have dirty version 0.1.1, got: %q", contents)
}
if strings.Contains(contents, `version="0.1.0"`) {
t.Errorf("archived plugin.mod should NOT have HEAD version 0.1.0, got: %q", contents)
}
// Working tree should be unchanged after stash-create.
postContents, err := os.ReadFile(modPath)
if err != nil {
t.Fatal(err)
}
if string(postContents) != string(dirtyContents) {
t.Errorf("working tree mutated after BuildSourceArchive\nwant: %q\ngot: %q",
string(dirtyContents), string(postContents))
}
}
func TestBuildSourceArchive_DirtyTreeOmitsUntracked(t *testing.T) {
dir := t.TempDir()
runGitArchive(t, dir, "init", "-q")
modPath := filepath.Join(dir, "plugin.mod")
if err := os.WriteFile(modPath,
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
runGitArchive(t, dir, "add", "plugin.mod")
runGitArchive(t, dir, "commit", "-qm", "init")
// Dirty the tracked file.
if err := os.WriteFile(modPath,
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n"), 0o644); err != nil {
t.Fatal(err)
}
// Add an untracked file (no git add).
if err := os.WriteFile(filepath.Join(dir, "extra.txt"), []byte("not tracked"), 0o644); err != nil {
t.Fatal(err)
}
zstdBytes, err := BuildSourceArchive(dir)
if err != nil {
t.Fatalf("BuildSourceArchive: %v", err)
}
got := readArchive(t, zstdBytes)
if _, ok := got["extra.txt"]; ok {
t.Errorf("untracked extra.txt should not be in archive, got %v", keys(got))
}
}
func runGitArchive(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", 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("git %v: %v\n%s", args, err, out)
}
}
func readArchive(t *testing.T, zstdBytes []byte) map[string]string {
t.Helper()
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)
}
return got
}