diff --git a/cmd/ninja/cmd/plugin.go b/cmd/ninja/cmd/plugin.go index 17ca513..6525681 100644 --- a/cmd/ninja/cmd/plugin.go +++ b/cmd/ninja/cmd/plugin.go @@ -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 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..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 ` 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 ` 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) - + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = repoDir + out, _ := cmd.Output() + dirty := strings.TrimSpace(string(out)) + if dirty == "" { + return nil + } if !allowDirty { - cmd := exec.Command("git", "status", "--porcelain") - cmd.Dir = repoDir - out, _ := cmd.Output() - if len(strings.TrimSpace(string(out))) > 0 { - 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) + return fmt.Errorf("working tree dirty (--strict); commit your changes or drop --strict") } - - submoduleWarning(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) + } + _, _ = 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. // diff --git a/cmd/ninja/cmd/plugin_test.go b/cmd/ninja/cmd/plugin_test.go index e4870fc..e4d8425 100644 --- a/cmd/ninja/cmd/plugin_test.go +++ b/cmd/ninja/cmd/plugin_test.go @@ -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") - var buf bytes.Buffer - gitignoredTrackedWarning(dir, &buf) - if buf.Len() != 0 { - t.Errorf("expected empty output for clean repo, got: %q", buf.String()) + for _, allowDirty := range []bool{true, false} { + var buf bytes.Buffer + 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 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()) } }) } diff --git a/internal/archive/archive.go b/internal/archive/archive.go deleted file mode 100644 index ceb5d45..0000000 --- a/internal/archive/archive.go +++ /dev/null @@ -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 -} diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go deleted file mode 100644 index 4454e78..0000000 --- a/internal/archive/archive_test.go +++ /dev/null @@ -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 -}