diff --git a/internal/generate-assets/main.go b/internal/generate-assets/main.go index eec3d68a67c9..ff2deea0a81a 100644 --- a/internal/generate-assets/main.go +++ b/internal/generate-assets/main.go @@ -28,8 +28,9 @@ func init() { {{- end }} }`)) - output = flag.String("o", "/dev/stdout", "output") - tags = flag.String("tags", "", "tags") + output = flag.String("o", "/dev/stdout", "output") + trimPrefix = flag.String("trimprefix", "", "trim prefix") + tags = flag.String("tags", "", "tags") ) func printMultiLineString(s []byte) string { @@ -49,7 +50,7 @@ func run() error { assets := make(map[string][]byte) for _, arg := range flag.Args() { var err error - assets[arg], err = ioutil.ReadFile(arg) + assets[strings.TrimPrefix(arg, *trimPrefix)], err = ioutil.ReadFile(arg) if err != nil { return err } diff --git a/v2/cmd/config.go b/v2/cmd/config.go index 76273a2c86c6..923aa2f0b274 100644 --- a/v2/cmd/config.go +++ b/v2/cmd/config.go @@ -6,8 +6,10 @@ import ( "io" "os" "os/user" + "path/filepath" "regexp" "runtime" + "sort" "strings" "text/template" "unicode" @@ -35,22 +37,26 @@ type templateConfig struct { // A Config represents a configuration. type Config struct { - configFile string - err error - fs vfs.FS - system chezmoi.System - SourceDir string - DestDir string - Umask permValue - DryRun bool - Follow bool - Remove bool - Verbose bool - Color string - Debug bool - SourceVCS sourceVCSConfig - Data map[string]interface{} - Template templateConfig + configFile string + err error + fs vfs.FS + system chezmoi.System + SourceDir string + DestDir string + Umask permValue + DryRun bool + Follow bool + Remove bool + Verbose bool + Color string + Output string // FIXME + Debug bool + SourceVCS sourceVCSConfig + Data map[string]interface{} + Template templateConfig + + data dataCmdConfig + scriptStateBucket []byte Stdin io.Reader Stdout io.Writer @@ -87,6 +93,9 @@ func newConfig(options ...configOption) *Config { funcs: sprig.TxtFuncMap(), Options: chezmoi.DefaultTemplateOptions, }, + data: dataCmdConfig{ + Format: "json", + }, scriptStateBucket: []byte("script"), Stdin: os.Stdin, Stdout: os.Stdout, @@ -116,6 +125,40 @@ func (c *Config) ensureNoError(cmd *cobra.Command, args []string) error { return nil } +func (c *Config) ensureSourceDirectory() error { + info, err := c.fs.Stat(c.SourceDir) + switch { + case err == nil && info.IsDir(): + if chezmoi.POSIXFileModes && info.Mode()&0o77 != 0 { + return c.system.Chmod(c.SourceDir, 0o700&^os.FileMode(c.Umask)) + } + return nil + case os.IsNotExist(err): + if err := vfs.MkdirAll(c.system, filepath.Dir(c.SourceDir), 0o777&^os.FileMode(c.Umask)); err != nil { + return err + } + return c.system.Mkdir(c.SourceDir, 0o700&^os.FileMode(c.Umask)) + case err == nil: + return fmt.Errorf("%s: not a directory", c.SourceDir) + default: + return err + } +} + +func (c *Config) getData() (map[string]interface{}, error) { + defaultData, err := c.getDefaultData() + if err != nil { + return nil, err + } + data := map[string]interface{}{ + "chezmoi": defaultData, + } + for key, value := range c.Data { + data[key] = value + } + return data, nil +} + func (c *Config) getDefaultData() (map[string]interface{}, error) { data := map[string]interface{}{ "arch": runtime.GOARCH, @@ -177,7 +220,7 @@ func (c *Config) getDefaultData() (map[string]interface{}, error) { return data, nil } -func (c *Config) getEditor() (string, []string) { +func getEditor() (string, []string) { editor := os.Getenv("VISUAL") if editor == "" { editor = os.Getenv("EDITOR") @@ -189,6 +232,14 @@ func (c *Config) getEditor() (string, []string) { return components[0], components[1:] } +func getSerializationFormat(name string) (chezmoi.SerializationFormat, error) { + serializationFormat, ok := chezmoi.SerializationFormats[strings.ToLower(name)] + if !ok { + return nil, fmt.Errorf("unknown serialization format: %s", name) + } + return serializationFormat, nil +} + // isWellKnownAbbreviation returns true if word is a well known abbreviation. func isWellKnownAbbreviation(word string) bool { _, ok := wellKnownAbbreviations[word] @@ -201,6 +252,25 @@ func panicOnError(err error) { } } +func serializationFormatNamesStr() string { + names := make([]string, 0, len(chezmoi.SerializationFormats)) + for name := range chezmoi.SerializationFormats { + names = append(names, strings.ToLower(name)) + } + sort.Strings(names) + switch len(names) { + case 0: + return "" + case 1: + return names[0] + case 2: + return names[0] + " or " + names[1] + default: + names[len(names)-1] = "or " + names[len(names)-1] + return strings.Join(names, ", ") + } +} + // titilize returns s, titilized. func titilize(s string) string { if s == "" { diff --git a/v2/cmd/data.go b/v2/cmd/data.go new file mode 100644 index 000000000000..1ed21209a14c --- /dev/null +++ b/v2/cmd/data.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +type dataCmdConfig struct { + Format string +} + +var dataCmd = &cobra.Command{ + Use: "data", + Args: cobra.NoArgs, + Short: "Print the template data", + Long: mustGetLongHelp("data"), + Example: getExample("data"), + PreRunE: config.ensureNoError, + RunE: config.runDataCmd, +} + +func init() { + rootCmd.AddCommand(dataCmd) + + persistentFlags := dataCmd.PersistentFlags() + persistentFlags.StringVarP(&config.data.Format, "format", "f", config.data.Format, "format ("+serializationFormatNamesStr()+")") +} + +func (c *Config) runDataCmd(cmd *cobra.Command, args []string) error { + serializationFormat, err := getSerializationFormat(c.data.Format) + if err != nil { + return err + } + data, err := c.getData() + if err != nil { + return err + } + serializedData, err := serializationFormat.Serialize(data) + if err != nil { + return err + } + _, err = c.Stdout.Write(serializedData) + return err +} diff --git a/v2/internal/chezmoi/chezmoi.go b/v2/internal/chezmoi/chezmoi.go index 9996a5a74928..ea820e009887 100644 --- a/v2/internal/chezmoi/chezmoi.go +++ b/v2/internal/chezmoi/chezmoi.go @@ -8,7 +8,7 @@ import ( // Configuration constants. const ( - posixFileModes = runtime.GOOS != "windows" + POSIXFileModes = runtime.GOOS != "windows" pathSeparator = '/' pathSeparatorStr = string(pathSeparator) ignorePrefix = "." diff --git a/v2/internal/chezmoi/datasystem_test.go b/v2/internal/chezmoi/datasystem_test.go index 6642a8aa51ca..7fd23beb0347 100644 --- a/v2/internal/chezmoi/datasystem_test.go +++ b/v2/internal/chezmoi/datasystem_test.go @@ -65,12 +65,8 @@ func TestDataSystem(t *testing.T) { actualData := dataSystem.Data() assert.Equal(t, expectedData, actualData) - for _, serializationFormat := range []SerializationFormat{ - JSONSerializationFormat, - TOMLSerializationFormat, - YAMLSerializationFormat, - } { - t.Run(serializationFormat.Name(), func(t *testing.T) { + for name, serializationFormat := range SerializationFormats { + t.Run(name, func(t *testing.T) { expectedSerializedData, err := serializationFormat.Serialize(expectedData) require.NoError(t, err) actualSerializedData, err := serializationFormat.Serialize(actualData) diff --git a/v2/internal/chezmoi/encryptiontool_test.go b/v2/internal/chezmoi/encryptiontool_test.go index 53069b2997ce..620bda1ef118 100644 --- a/v2/internal/chezmoi/encryptiontool_test.go +++ b/v2/internal/chezmoi/encryptiontool_test.go @@ -120,7 +120,7 @@ func testEncryptionToolEncryptFile(t *testing.T, et EncryptionTool) { defer func() { assert.NoError(t, os.RemoveAll(tempFile.Name())) }() - if posixFileModes { + if POSIXFileModes { require.NoError(t, tempFile.Chmod(0o600)) } _, err = tempFile.Write(expectedPlaintext) diff --git a/v2/internal/chezmoi/gpgencryptiontool.go b/v2/internal/chezmoi/gpgencryptiontool.go index 94f4be69bdd8..c851e60a2b28 100644 --- a/v2/internal/chezmoi/gpgencryptiontool.go +++ b/v2/internal/chezmoi/gpgencryptiontool.go @@ -77,7 +77,7 @@ func (t *GPGEncryptionTool) Encrypt(plaintext []byte) (ciphertext []byte, err er err = multierr.Append(err, os.RemoveAll(tempFile.Name())) }() - if posixFileModes { + if POSIXFileModes { if err = tempFile.Chmod(0o600); err != nil { return } diff --git a/v2/internal/chezmoi/gpgencryptiontool_test.go b/v2/internal/chezmoi/gpgencryptiontool_test.go index 15ab45d21718..7afb3f6b8ee2 100644 --- a/v2/internal/chezmoi/gpgencryptiontool_test.go +++ b/v2/internal/chezmoi/gpgencryptiontool_test.go @@ -23,7 +23,7 @@ func TestGPGEncryptionTool(t *testing.T) { assert.NoError(t, os.RemoveAll(tempDir)) }() - if posixFileModes { + if POSIXFileModes { require.NoError(t, os.Chmod(tempDir, 0o700)) } diff --git a/v2/internal/chezmoi/jsonserializationformat.go b/v2/internal/chezmoi/jsonserializationformat.go index 823969133bfd..43b6cddf4170 100644 --- a/v2/internal/chezmoi/jsonserializationformat.go +++ b/v2/internal/chezmoi/jsonserializationformat.go @@ -31,3 +31,7 @@ func (jsonSerializationFormat) Deserialize(data []byte) (interface{}, error) { } return result, nil } + +func init() { + SerializationFormats[JSONSerializationFormat.Name()] = JSONSerializationFormat +} diff --git a/v2/internal/chezmoi/realsystem.go b/v2/internal/chezmoi/realsystem.go index f451e18c3235..d80df34d42e8 100644 --- a/v2/internal/chezmoi/realsystem.go +++ b/v2/internal/chezmoi/realsystem.go @@ -33,7 +33,7 @@ func NewRealSystem(fs vfs.FS, persistentState PersistentState) *RealSystem { // Chmod implements System.Glob. func (s *RealSystem) Chmod(name string, mode os.FileMode) error { - if !posixFileModes { + if !POSIXFileModes { return nil } return s.FS.Chmod(name, mode) @@ -78,7 +78,7 @@ func (s *RealSystem) RunScript(scriptname string, data []byte) (err error) { // Make the script private before writing it in case it contains any // secrets. - if posixFileModes { + if POSIXFileModes { if err = f.Chmod(0o700); err != nil { return } @@ -129,7 +129,7 @@ func WriteFile(fs vfs.FS, filename string, data []byte, perm os.FileMode) (err e // Set permissions after truncation but before writing any data, in case the // file contained private data before, but before writing the new contents, // in case the contents contain private data after. - if posixFileModes { + if POSIXFileModes { if err = f.Chmod(perm); err != nil { return } diff --git a/v2/internal/chezmoi/serializationformat.go b/v2/internal/chezmoi/serializationformat.go index 14c7ffe15207..56d8eedd94c8 100644 --- a/v2/internal/chezmoi/serializationformat.go +++ b/v2/internal/chezmoi/serializationformat.go @@ -6,3 +6,6 @@ type SerializationFormat interface { Name() string Serialize(data interface{}) ([]byte, error) } + +// SerializationFormats is a map of all SerializationFormats by name. +var SerializationFormats = make(map[string]SerializationFormat) diff --git a/v2/internal/chezmoi/serializationformat_test.go b/v2/internal/chezmoi/serializationformat_test.go new file mode 100644 index 000000000000..06e20dd128a0 --- /dev/null +++ b/v2/internal/chezmoi/serializationformat_test.go @@ -0,0 +1,13 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSerializationFormats(t *testing.T) { + assert.Contains(t, SerializationFormats, "json") + assert.Contains(t, SerializationFormats, "toml") + assert.Contains(t, SerializationFormats, "yaml") +} diff --git a/v2/internal/chezmoi/targetstateentry.go b/v2/internal/chezmoi/targetstateentry.go index ecdaf9f3e4a6..59255b28f241 100644 --- a/v2/internal/chezmoi/targetstateentry.go +++ b/v2/internal/chezmoi/targetstateentry.go @@ -143,7 +143,7 @@ func (t *TargetStateFile) Equal(destStateEntry DestStateEntry) (bool, error) { log.Printf("other is a %T, not a *DestStateFile\n", destStateEntry) return false, nil } - if posixFileModes && destStateFile.perm != t.perm { + if POSIXFileModes && destStateFile.perm != t.perm { log.Printf("other has perm %o, want %o", destStateFile.perm, t.perm) return false, nil } diff --git a/v2/internal/chezmoi/tomlserializationformat.go b/v2/internal/chezmoi/tomlserializationformat.go index 75e2a2237925..2918450a5073 100644 --- a/v2/internal/chezmoi/tomlserializationformat.go +++ b/v2/internal/chezmoi/tomlserializationformat.go @@ -22,3 +22,7 @@ func (tomlSerializationFormat) Deserialize(data []byte) (interface{}, error) { } return result, nil } + +func init() { + SerializationFormats[TOMLSerializationFormat.Name()] = TOMLSerializationFormat +} diff --git a/v2/internal/chezmoi/yamlserializationformat.go b/v2/internal/chezmoi/yamlserializationformat.go index 1c2dee7d7283..84ba6626cba2 100644 --- a/v2/internal/chezmoi/yamlserializationformat.go +++ b/v2/internal/chezmoi/yamlserializationformat.go @@ -22,3 +22,7 @@ func (yamlSerializationFormat) Deserialize(data []byte) (interface{}, error) { } return result, nil } + +func init() { + SerializationFormats[YAMLSerializationFormat.Name()] = YAMLSerializationFormat +} diff --git a/v2/main_test.go b/v2/main_test.go new file mode 100644 index 000000000000..effae250f128 --- /dev/null +++ b/v2/main_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/rogpeppe/go-internal/testscript" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" +) + +//nolint:interfacer +func TestMain(m *testing.M) { + os.Exit(testscript.RunMain(m, map[string]func() int{ + "chezmoi": testRun, + })) +} + +func TestChezmoi(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration tests in short mode") + } + testscript.Run(t, testscript.Params{ + Dir: filepath.Join("testdata", "scripts"), + Cmds: map[string]func(*testscript.TestScript, bool, []string){ + "chhome": chHome, + "edit": edit, + }, + Condition: func(cond string) (bool, error) { + switch cond { + case "windows": + return runtime.GOOS == "windows", nil + default: + return false, fmt.Errorf("unknown condition: %s", cond) + } + }, + Setup: setup, + }) +} + +func testRun() int { + if err := run(); err != nil { + if s := err.Error(); s != "" { + fmt.Printf("chezmoi: %s\n", s) + } + return 1 + } + return 0 +} + +// chHome changes the home directory to its argument, creating the directory if +// it does not already exists. It updates the HOME environment variable, and, if +// running on Windows, USERPROFILE too. +func chHome(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported ! chhome") + } + if len(args) != 1 { + ts.Fatalf("usage: chhome dir") + } + homeDir := args[0] + if !filepath.IsAbs(homeDir) { + homeDir = filepath.Join(ts.Getenv("WORK"), homeDir) + } + ts.Check(os.MkdirAll(homeDir, 0o777)) + ts.Setenv("HOME", homeDir) + if runtime.GOOS == "windows" { + ts.Setenv("USERPROFILE", homeDir) + } +} + +// edit edits all of its arguments by appending "# edited\n" to them. +func edit(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported ! edit") + } + for _, arg := range args { + filename := ts.MkAbs(arg) + data, err := ioutil.ReadFile(filename) + if err != nil { + ts.Fatalf("edit: %v", err) + } + data = append(data, []byte("# edited\n")...) + if err := ioutil.WriteFile(filename, data, 0o666); err != nil { + ts.Fatalf("edit: %v", err) + } + } +} + +func setup(env *testscript.Env) error { + var ( + binDir = filepath.Join(env.WorkDir, "bin") + homeDir = filepath.Join(env.WorkDir, "home", "user") + chezmoiConfigDir = filepath.Join(homeDir, ".config", "chezmoi") + chezmoiSourceDir = filepath.Join(homeDir, ".local", "share", "chezmoi") + ) + + env.Setenv("HOME", homeDir) + env.Setenv("PATH", prependDirToPath(binDir, env.Getenv("PATH"))) + env.Setenv("CHEZMOICONFIGDIR", chezmoiConfigDir) + env.Setenv("CHEZMOISOURCEDIR", chezmoiSourceDir) + switch runtime.GOOS { + case "windows": + env.Setenv("EDITOR", filepath.Join(binDir, "editor.cmd")) + env.Setenv("USERPROFILE", homeDir) + // There is not currently a convenient way to override the shell on + // Windows. + default: + env.Setenv("EDITOR", filepath.Join(binDir, "editor")) + env.Setenv("SHELL", filepath.Join(binDir, "shell")) + } + + root := map[string]interface{}{ + "/home/user": map[string]interface{}{ + // .gitconfig is populated with a user and email to avoid warnings + // from git. + ".gitconfig": strings.Join([]string{ + `[user]`, + ` name = Username`, + ` email = user@home.org`, + }, "\n"), + }, + } + + switch runtime.GOOS { + case "windows": + root["/bin"] = map[string]interface{}{ + // editor.cmd a non-interactive script that appends "# edited\n" to + // the end of each file. + "editor.cmd": &vfst.File{ + Perm: 0o755, + Contents: []byte(`@for %%x in (%*) do echo # edited>>%%x`), + }, + } + default: + root["/bin"] = map[string]interface{}{ + // editor a non-interactive script that appends "# edited\n" to the + // end of each file. + "editor": &vfst.File{ + Perm: 0o755, + Contents: []byte(strings.Join([]string{ + `#!/bin/sh`, + ``, + `for filename in $*; do`, + ` echo "# edited" >> $filename`, + `done`, + }, "\n")), + }, + // shell is a non-interactive script that appends the directory in + // which it was launched to $WORK/shell.log. + "shell": &vfst.File{ + Perm: 0o755, + Contents: []byte(strings.Join([]string{ + `#!/bin/sh`, + ``, + `echo $PWD >> ` + filepath.Join(env.WorkDir, "shell.log"), + }, "\n")), + }, + } + } + + return vfst.NewBuilder().Build(vfs.NewPathFS(vfs.HostOSFS, env.WorkDir), root) +} + +func prependDirToPath(dir, path string) string { + return strings.Join(append([]string{dir}, filepath.SplitList(path)...), string(os.PathListSeparator)) +} diff --git a/v2/testdata/scripts/data.txt b/v2/testdata/scripts/data.txt new file mode 100644 index 000000000000..ce513fba7909 --- /dev/null +++ b/v2/testdata/scripts/data.txt @@ -0,0 +1,11 @@ +chezmoi data +stdout '"chezmoi":' + +chezmoi data --format=json +stdout '"chezmoi":' + +chezmoi data --format=toml +stdout '[chezmoi]' + +chezmoi data --format=yaml +stdout 'chezmoi:'