Compare commits

...

13 Commits

Author SHA1 Message Date
Alex Dunmow
46e3389045 test(cli): cover emitPublishWarnings across all three warning paths
Proves the publish command's warning surface end-to-end: tracked-yet-
gitignored files, declared submodules, untracked files on --allow-dirty,
and that the dirty-tree abort suppresses the untracked warning.
2026-06-03 10:15:24 +08:00
Alex Dunmow
824d55a1fa refactor(cli): extract publish-time warnings into emitPublishWarnings
Pulls the three warning calls and the dirty-tree check out of the
publish RunE closure into a single helper so a refactor that drops one
warning can be caught by a fixture-based test.
2026-06-03 10:14:50 +08:00
Alex Dunmow
ea744888ae test(cli): lock in trailing newline on gitignored-tracked warning
Guards against a silent refactor from Fprintln to Fprint dropping the
terminating newline that downstream output relies on.
2026-06-03 10:14:06 +08:00
Alex Dunmow
3d62071f77 test(cli): cover autoCommitPluginMod detached HEAD and missing git
Adds coverage for two git-edge cases: commits land correctly on a
detached HEAD with the prior commit as parent, and an empty PATH
produces a git-mentioning error rather than a panic.
2026-06-03 10:13:53 +08:00
Alex Dunmow
e076a03c33 test(cli): cover publish-time warning helpers
Add fires/no-op pairs for gitignoredTrackedWarning, untrackedFilesWarning,
and submoduleWarning. The submodule case writes a hand-crafted .gitmodules
file rather than wiring real submodules — submodulePaths reads the file
directly so that's sufficient.
2026-06-03 09:13:00 +08:00
Alex Dunmow
fda01e81b5 refactor(cli): extract publish-time warnings into testable helpers
Pull the three inline warning blocks in newPluginPublishCmd —
gitignoredTrackedWarning, untrackedFilesWarning, submoduleWarning — into
package-private helpers that take a repo dir and an io.Writer. Output is
byte-identical to the previous inline code; this just makes them unit-
testable without driving the whole cobra command.
2026-06-03 09:12:56 +08:00
Alex Dunmow
421f5ee0cb test(cli): cover autoCommitPluginMod commit and no-op paths
autoCommitPluginMod runs `git status --porcelain plugin.mod` then commits if
dirty. Add two cases: dirty plugin.mod produces an "Add plugin.mod" commit,
and a clean state leaves HEAD unchanged. Uses t.Chdir to scope CWD to the
temp repo without polluting parent state.
2026-06-03 09:11:37 +08:00
Alex Dunmow
ee76d76dc6 test(cli): cover BuildSourceArchive dirty-tree stash-create path
The dirty-tree branch (where git stash create captures uncommitted tracked
changes) was untested. Add two cases: one asserting the archive contains
the dirty working-copy contents (not HEAD) and the working tree is not
mutated; another asserting untracked files are excluded — the contract
the --allow-dirty publish warning relies on.
2026-06-03 09:11:08 +08:00
Alex Dunmow
ab465ef07c fix(cli): run gitignore-tracked warning before dirty-check so it fires unconditionally 2026-06-03 08:59:41 +08:00
Alex Dunmow
137a50c932 fix(cli): warn when publishing a repo that contains submodules
`git archive` does not recurse into submodules, so a plugin shipping
vendored code via submodule produced a tarball where the submodule path
existed but was empty — silent failure. Now publish reads .gitmodules
and lists submodule paths to stderr with guidance to vendor or pack
them separately. The publish still proceeds, since the developer may
not actually need the submodule contents in the archive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:57:46 +08:00
Alex Dunmow
c3cfa18ae0 fix(cli): warn about untracked files when publishing with --allow-dirty
`git stash create` only captures tracked content, so a developer using
--allow-dirty after creating new files (but forgetting to `git add`)
would ship a tarball missing them with no indication. Now publish lists
the untracked, non-ignored files to stderr and suggests `git add` when
--allow-dirty is in play.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:56:43 +08:00
Alex Dunmow
4c0104619e fix(cli): friendly error when publishing from a repo with no commits
Previously a brand-new repo (git init, no commits) surfaced `git stash
create: exit status 128: You do not have the initial commit yet` from
deep inside the archive helper. Now the publish flow detects this case
via `git rev-parse --verify HEAD` up front and prints "no commits in
repository; run `git add . && git commit` before publishing". Also
updates the init flow's hint to mention `git init && git commit` so
users aren't misled into thinking `git init` alone is enough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:56:27 +08:00
Alex Dunmow
20a7b35e50 docs(sdk): document Coords scope normalisation and accept-both contract
Adds a doc comment to ModFile.Coords explaining the leading-@ trim and a
note on ModPlugin.Scope clarifying that consumers should trim "@" before
comparing. Locks in the contract with a test asserting both call shapes
produce the same display string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:55:46 +08:00
5 changed files with 688 additions and 14 deletions

View File

@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"context" "context"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"regexp" "regexp"
@ -109,7 +110,7 @@ func newPluginInitCmd() *cobra.Command {
fmt.Fprintf(os.Stderr, "warning: could not commit plugin.mod: %v\n", err) fmt.Fprintf(os.Stderr, "warning: could not commit plugin.mod: %v\n", err)
} }
} else { } else {
fmt.Println("Not in a git repo - run `git init` before `ninja plugin publish`") fmt.Println("Not in a git repo - run `git init && git commit` before `ninja plugin publish`")
} }
return nil return nil
}, },
@ -294,20 +295,12 @@ func newPluginPublishCmd() *cobra.Command {
return fmt.Errorf("plugin.mod must have scope, name, and version") return fmt.Errorf("plugin.mod must have scope, name, and version")
} }
if !allowDirty { if err := checkRepoHasHEAD("."); err != nil {
out, _ := exec.Command("git", "status", "--porcelain").Output() return err
if len(strings.TrimSpace(string(out))) > 0 {
return fmt.Errorf("working tree dirty; commit or pass --allow-dirty")
}
} }
out, _ := exec.Command("git", "ls-files", "--cached", "--ignored", "--exclude-standard").Output() if err := emitPublishWarnings(".", allowDirty, os.Stderr); err != nil {
if names := strings.TrimSpace(string(out)); names != "" { return err
fmt.Fprintln(os.Stderr, "warning: these tracked files match .gitignore and will still be shipped:")
for _, n := range strings.Split(names, "\n") {
fmt.Fprintln(os.Stderr, " "+n)
}
fmt.Fprintln(os.Stderr, " (run `git rm --cached <file>` to drop)")
} }
archiveBytes, err := archive.BuildSourceArchive(".") archiveBytes, err := archive.BuildSourceArchive(".")
@ -548,6 +541,41 @@ func runCmd(name string, args ...string) error {
return c.Run() return c.Run()
} }
// 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.
func checkRepoHasHEAD(repoDir string) error {
cmd := exec.Command("git", "rev-parse", "--verify", "HEAD")
cmd.Dir = repoDir
if err := cmd.Run(); err != nil {
return fmt.Errorf("no commits in repository; run `git add . && git commit` before publishing")
}
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.Split(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, kind string, categories []string) error { func upsertPluginMod(scope, name, kind string, categories []string) error {
const file = "plugin.mod" const file = "plugin.mod"
existing, _ := os.ReadFile(file) existing, _ := os.ReadFile(file)
@ -593,6 +621,90 @@ func writeMod(path string, m *core.ModFile) error {
return os.WriteFile(path, []byte(b.String()), 0o644) 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.Split(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. Used on the --allow-dirty 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: --allow-dirty: these untracked files will NOT be in the archive:")
for _, n := range strings.Split(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:
//
// 1. gitignoredTrackedWarning is unconditional so the developer sees it even
// when the publish is about to abort.
// 2. If allowDirty is false 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 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.
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 {
return fmt.Errorf("working tree dirty; commit or pass --allow-dirty")
}
} else {
// `git stash create` only captures tracked content, so untracked
// files would be silently dropped from the archive. Warn loudly.
untrackedFilesWarning(repoDir, w)
}
submoduleWarning(repoDir, w)
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 if it differs from // autoCommitPluginMod stages and commits plugin.mod if it differs from
// what's already at HEAD. No-op when there's nothing to commit. // what's already at HEAD. No-op when there's nothing to commit.
func autoCommitPluginMod() error { func autoCommitPluginMod() error {

View File

@ -0,0 +1,418 @@
package cmd
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCheckRepoHasHEAD_NoCommitsReturnsFriendlyError(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
err := checkRepoHasHEAD(dir)
if err == nil {
t.Fatal("expected error for repo with no commits, got nil")
}
if !strings.Contains(err.Error(), "no commits in repository") {
t.Errorf("error %q should mention 'no commits in repository'", err.Error())
}
if !strings.Contains(err.Error(), "git commit") {
t.Errorf("error %q should suggest `git commit`", err.Error())
}
}
func TestCheckRepoHasHEAD_WithCommitReturnsNil(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "f"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "f")
runGit(t, dir, "commit", "-qm", "init")
if err := checkRepoHasHEAD(dir); err != nil {
t.Errorf("expected nil for repo with a commit, got %v", err)
}
}
func TestAutoCommitPluginMod_CommitsWhenDirty(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, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod(); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
subject := gitLogSubject(t, dir)
if subject != "Add plugin.mod" {
t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject)
}
}
func TestAutoCommitPluginMod_NoopWhenClean(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "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)
}
runGit(t, dir, "add", "plugin.mod")
runGit(t, dir, "commit", "-qm", "seed")
beforeSHA := gitHeadSHA(t, dir)
t.Chdir(dir)
if err := autoCommitPluginMod(); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
afterSHA := gitHeadSHA(t, dir)
if afterSHA != beforeSHA {
t.Errorf("expected no new commit, HEAD moved %s -> %s", beforeSHA, afterSHA)
}
}
func TestAutoCommitPluginMod_WorksOnDetachedHEAD(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")
initialSHA := gitHeadSHA(t, dir)
runGit(t, dir, "checkout", "-q", initialSHA)
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)
}
t.Chdir(dir)
if err := autoCommitPluginMod(); err != nil {
t.Fatalf("autoCommitPluginMod on detached HEAD: %v", err)
}
afterSHA := gitHeadSHA(t, dir)
if afterSHA == initialSHA {
t.Fatalf("expected new commit on detached HEAD, HEAD still at %s", afterSHA)
}
subject := gitLogSubject(t, dir)
if subject != "Add plugin.mod" {
t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject)
}
parentCmd := exec.Command("git", "rev-parse", "HEAD^")
parentCmd.Dir = dir
parentOut, err := parentCmd.CombinedOutput()
if err != nil {
t.Fatalf("git rev-parse HEAD^: %v\n%s", err, parentOut)
}
parentSHA := strings.TrimSpace(string(parentOut))
if parentSHA != initialSHA {
t.Errorf("expected new commit parent to be %s, got %s", initialSHA, parentSHA)
}
}
func TestAutoCommitPluginMod_ErrorsWhenGitMissing(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, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
t.Setenv("PATH", "")
err := autoCommitPluginMod()
if err == nil {
t.Fatal("expected error when git is missing from PATH, got nil")
}
if !strings.Contains(err.Error(), "git") {
t.Errorf("error %q should mention 'git'", err.Error())
}
}
func gitLogSubject(t *testing.T, dir string) string {
t.Helper()
cmd := exec.Command("git", "log", "-1", "--pretty=%s")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git log: %v\n%s", err, out)
}
return strings.TrimSpace(string(out))
}
func gitHeadSHA(t *testing.T, dir string) string {
t.Helper()
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git rev-parse: %v\n%s", err, out)
}
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) {
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
gitignoredTrackedWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output for clean repo, got: %q", 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) {
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, "scratch.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
t.Run("allowDirty=true surfaces untracked warning", 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)
}
if !strings.Contains(out, "--allow-dirty") {
t.Errorf("expected untracked-files warning fragment, got: %q", out)
}
})
t.Run("allowDirty=false aborts before untracked warning", func(t *testing.T) {
var buf bytes.Buffer
err := emitPublishWarnings(dir, false, &buf)
if err == nil {
t.Fatal("expected dirty-tree error, got nil")
}
if !strings.Contains(err.Error(), "working tree dirty") {
t.Errorf("expected dirty-tree error, got: %v", err)
}
if strings.Contains(buf.String(), "--allow-dirty: these untracked files") {
t.Errorf("untracked-files warning should not fire on dirty-abort path, got: %q", buf.String())
}
})
}
func runGit(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)
}
}

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
@ -87,3 +88,121 @@ func keys(m map[string]string) []string {
} }
return out 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
}

View File

@ -13,7 +13,11 @@ type ModFile struct {
} }
type ModPlugin struct { type ModPlugin struct {
Name string `toml:"name"` Name string `toml:"name"`
// Scope is the plugin owner namespace as it appears in plugin.mod. It may
// include the leading "@" (e.g. "@themes") or omit it (e.g. "themes") —
// both forms are accepted. Consumers comparing scopes should trim the "@"
// before comparing; use ModFile.Coords() for a normalised display string.
Scope string `toml:"scope"` Scope string `toml:"scope"`
Version string `toml:"version"` Version string `toml:"version"`
Kind string `toml:"kind,omitempty"` Kind string `toml:"kind,omitempty"`
@ -37,6 +41,13 @@ func ParseModFull(b []byte) (*ModFile, error) {
return &m, nil return &m, nil
} }
// Coords returns the canonical display coordinate for the plugin in the form
// "@scope/name@version" (or "name@version" when no scope is set).
//
// The leading "@" on m.Plugin.Scope is intentionally trimmed before
// re-prefixing so that authors may write either "@themes" or "themes" in
// plugin.mod and get the same output. Callers that need the raw scope as
// written should read m.Plugin.Scope directly.
func (m *ModFile) Coords() string { func (m *ModFile) Coords() string {
if m == nil { if m == nil {
return "" return ""

View File

@ -100,6 +100,20 @@ version = "0.1.0"
} }
} }
func TestCoords_AcceptsScopeWithOrWithoutAt(t *testing.T) {
want := "@themes/foo@1.0.0"
withAt := &ModFile{Plugin: ModPlugin{Name: "foo", Scope: "@themes", Version: "1.0.0"}}
if got := withAt.Coords(); got != want {
t.Errorf("Coords() with leading @ = %q, want %q", got, want)
}
withoutAt := &ModFile{Plugin: ModPlugin{Name: "foo", Scope: "themes", Version: "1.0.0"}}
if got := withoutAt.Coords(); got != want {
t.Errorf("Coords() without leading @ = %q, want %q", got, want)
}
}
func TestParseModFull_RequiresAndCompat(t *testing.T) { func TestParseModFull_RequiresAndCompat(t *testing.T) {
src := []byte(` src := []byte(`
[plugin] [plugin]