Compare commits
13 Commits
7af42c1c83
...
46e3389045
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e3389045 | ||
|
|
824d55a1fa | ||
|
|
ea744888ae | ||
|
|
3d62071f77 | ||
|
|
e076a03c33 | ||
|
|
fda01e81b5 | ||
|
|
421f5ee0cb | ||
|
|
ee76d76dc6 | ||
|
|
ab465ef07c | ||
|
|
137a50c932 | ||
|
|
c3cfa18ae0 | ||
|
|
4c0104619e | ||
|
|
20a7b35e50 |
@ -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 {
|
||||||
|
|||||||
418
cmd/ninja/cmd/plugin_test.go
Normal file
418
cmd/ninja/cmd/plugin_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user