diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 87a44691342d..bf777c17fc4a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,6 +28,11 @@ jobs: key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- + - name: Install age + run: | + git clone https://github.com/FiloSottile/age + cd age + go install ./cmd/... - name: Checkout uses: actions/checkout@v2 - name: Build diff --git a/.gitignore b/.gitignore index d3712c9948ce..c326f705e931 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*.exe /bin/chezmoi /bin/gofumports /bin/golangci-lint diff --git a/.golangci.yml b/.golangci.yml index b2fe31982bb6..1db611fd59f5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -12,6 +12,7 @@ linters: - exportloopref - forbidigo - gci + - gochecknoinits - gocritic - godot - goerr113 @@ -28,8 +29,7 @@ linters: - interfacer - makezero - misspell - - nakedret - - nolintlint + - paralleltest - prealloc - predeclared - rowserrcheck @@ -50,7 +50,6 @@ linters: - exhaustivestruct - funlen - gochecknoglobals - - gochecknoinits - gocognit - goconst - gocyclo @@ -59,10 +58,11 @@ linters: - gomnd - lll - maligned + - nakedret - nestif - nlreturn - noctx - - paralleltest + - nolintlint # FIXME renable - testpackage - wrapcheck - wsl @@ -86,14 +86,26 @@ issues: - linters: - dupl path: "secretpass.go" + - linters: + - nolintlint + - paralleltest + path: "^main_test.go$" - linters: - forbidigo - gochecknoinits + - paralleltest path: cmd/ + - linters: + - gochecknoinits + path: internal/chezmoi/chezmoi_unix.go - linters: - forbidigo - gosec path: internal/cmd/ - linters: + - paralleltest + path: internal/ + - linters: + - gosec - scopelint path: "_test\\.go" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 25a33b07f1df..16c4f79eb80a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,7 +6,6 @@ before: builds: - id: chezmoi-cgo-glibc - binary: chezmoi env: - CGO_ENABLED=1 goos: @@ -14,7 +13,6 @@ builds: goarch: - amd64 - id: chezmoi-cgo-musl - binary: chezmoi env: - CC=/usr/bin/musl-gcc - CGO_ENABLED=1 @@ -32,7 +30,56 @@ builds: - '-linkmode external' - '--extldflags "-static"' - id: chezmoi-nocgo - binary: chezmoi + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - freebsd + - openbsd + - windows + goarch: + - 386 + - amd64 + - arm + - arm64 + - ppc64 + - ppc64le + goarm: + - "" + ignore: + - goos: darwin + goarch: 386 + - goos: linux + goarch: amd64 +- id: chezmoi2-cgo-glibc + main: ./chezmoi2/main.go + env: + - CGO_ENABLED=1 + goos: + - linux + goarch: + - amd64 +- id: chezmoi2-cgo-musl + main: ./chezmoi2/main.go + env: + - CC=/usr/bin/musl-gcc + - CGO_ENABLED=1 + goos: + - linux + goarch: + - amd64 + ldflags: + - '-s' + - '-w' + - '-X main.version={{.Version}}' + - '-X main.commit={{.Commit}}' + - '-X main.date={{.Date}}' + - '-X main.builtBy=goreleaser' + - '-linkmode external' + - '--extldflags "-static"' +- id: chezmoi2-nocgo + main: ./chezmoi2/main.go env: - CGO_ENABLED=0 goos: @@ -94,9 +141,11 @@ nfpms: dependencies: - git bindir: /usr/bin - files: - "completions/chezmoi-completion.bash": "/usr/share/bash-completion/completions/chezmoi" - "completions/chezmoi.fish": "/usr/share/fish/completions/chezmoi.fish" + contents: + - src: completions/chezmoi-completion.bash + dst: /usr/share/bash-completion/completions/chezmoi + - src: completions/chezmoi.fish + dst: /usr/share/fish/completions/chezmoi.fish overrides: deb: file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" @@ -112,8 +161,9 @@ nfpms: 386: i686 arm: armhfp arm64: aarch64 - files: - "completions/chezmoi.zsh": "/usr/share/zsh/functions/_chezmoi" + contents: + - src: completions/chezmoi.zsh + dst: /usr/share/zsh/functions/_chezmoi - id: apks builds: - chezmoi-cgo-musl diff --git a/Makefile b/Makefile index 38511ca2d51c..d25ce5523d4f 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,49 @@ GOLANGCI_LINT_VERSION=1.35.2 .PHONY: default -default: generate run test lint format +default: generate build run test lint format -.PHONT: generate -generate: - go generate +.PHONY: build +build: build-darwin build-linux build-windows -.PHONY: generate-install.sh -generate-install.sh: - go run ./internal/cmd/generate-install.sh > assets/scripts/install.sh +.PHONY: build-darwin +build-darwin: generate + GOOS=darwin GOARCH=amd64 go build -o /dev/null . + GOOS=darwin GOARCH=amd64 go build -o /dev/null ./chezmoi2 + +.PHONY: build-linux +build-linux: generate + GOOS=linux GOARCH=amd64 go build -o /dev/null . + GOOS=linux GOARCH=amd64 go build -o /dev/null ./chezmoi2 + +.PHONY: build-windows +build-windows: generate + GOOS=windows GOARCH=amd64 go build -o /dev/null . + GOOS=windows GOARCH=amd64 go build -o /dev/null ./chezmoi2 .PHONY: run -run: +run: generate go run . --version +.PHONY: generate +generate: + go generate + .PHONY: test -test: +test: generate go test ./... +.PHONY: generate-install.sh +generate-install.sh: + go run ./internal/cmd/generate-install.sh > assets/scripts/install.sh + .PHONY: lint -lint: ensure-golangci-lint +lint: ensure-golangci-lint generate ./bin/golangci-lint run go run ./internal/cmd/lint-whitespace .PHONY: format -format: ensure-gofumports +format: ensure-gofumports generate find . -name \*.go | xargs ./bin/gofumports -local github.com/twpayne/chezmoi -w .PHONY: ensure-tools diff --git a/chezmoi2/cmd/addcmd.go b/chezmoi2/cmd/addcmd.go new file mode 100644 index 000000000000..2d2227e5e45d --- /dev/null +++ b/chezmoi2/cmd/addcmd.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type addCmdConfig struct { + autoTemplate bool + empty bool + encrypt bool + exact bool + exists bool + follow bool + include *chezmoi.IncludeSet + recursive bool + template bool +} + +func (c *Config) newAddCmd() *cobra.Command { + addCmd := &cobra.Command{ + Use: "add targets...", + Aliases: []string{"manage"}, + Short: "Add an existing file, directory, or symlink to the source state", + Long: mustLongHelp("add"), + Example: example("add"), + Args: cobra.MinimumNArgs(1), + RunE: c.makeRunEWithSourceState(c.runAddCmd), + Annotations: map[string]string{ + modifiesSourceDirectory: "true", + persistentStateMode: persistentStateModeReadWrite, + requiresSourceDirectory: "true", + }, + } + + flags := addCmd.Flags() + flags.BoolVarP(&c.add.autoTemplate, "autotemplate", "a", c.add.autoTemplate, "auto generate the template when adding files as templates") + flags.BoolVarP(&c.add.empty, "empty", "e", c.add.empty, "add empty files") + flags.BoolVar(&c.add.encrypt, "encrypt", c.add.encrypt, "encrypt files") + flags.BoolVarP(&c.add.exact, "exact", "x", c.add.exact, "add directories exactly") + flags.BoolVar(&c.add.exists, "exists", c.add.exists, "add files that should exist, irrespective of their contents") + flags.BoolVarP(&c.add.follow, "follow", "f", c.add.follow, "add symlink targets instead of symlinks") + flags.BoolVarP(&c.add.recursive, "recursive", "r", c.add.recursive, "recursive") + flags.BoolVarP(&c.add.template, "template", "T", c.add.template, "add files as templates") + + return addCmd +} + +func (c *Config) runAddCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + destAbsPathInfos, err := c.destAbsPathInfos(sourceState, args, c.add.recursive, c.add.follow) + if err != nil { + return err + } + + return sourceState.Add(c.sourceSystem, c.persistentState, c.destSystem, destAbsPathInfos, &chezmoi.AddOptions{ + AutoTemplate: c.add.autoTemplate, + Empty: c.add.empty, + Encrypt: c.add.encrypt, + Exact: c.add.exact, + Exists: c.add.exists, + Include: c.add.include, + Template: c.add.template, + }) +} diff --git a/chezmoi2/cmd/addcmd_test.go b/chezmoi2/cmd/addcmd_test.go new file mode 100644 index 000000000000..ef701652459c --- /dev/null +++ b/chezmoi2/cmd/addcmd_test.go @@ -0,0 +1,245 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestAddCmd(t *testing.T) { + for _, tc := range []struct { + name string + root interface{} + args []string + tests []interface{} + }{ + { + name: "dir", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".dir": &vfst.Dir{Perm: 0o777}, + }, + }, + args: []string{"~/.dir"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^chezmoi.GetUmask()), + ), + }, + }, + { + name: "dir_with_file", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".dir": &vfst.Dir{ + Perm: 0o777, + Entries: map[string]interface{}{ + "file": "# contents of .dir/file\n", + }, + }, + }, + }, + args: []string{"~/.dir"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^chezmoi.GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .dir/file\n"), + ), + }, + }, + { + name: "dir_with_file_with_--recursive=false", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".dir": &vfst.Dir{ + Perm: 0o777, + Entries: map[string]interface{}{ + "file": "# contents of .dir/file\n", + }, + }, + }, + }, + args: []string{"~/.dir", "--recursive=false"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^chezmoi.GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "dir_private_unix", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".dir": &vfst.Dir{ + Perm: 0o700, + Entries: map[string]interface{}{ + "file": "# contents of .dir/file\n", + }, + }, + }, + }, + args: []string{"~/.dir"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^chezmoi.GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_dir/file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .dir/file\n"), + ), + }, + }, + { + name: "dir_file_private_unix", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".dir": &vfst.Dir{ + Perm: 0o700, + Entries: map[string]interface{}{ + "file": "# contents of .dir/file\n", + }, + }, + }, + }, + args: []string{"~/.dir/file"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^chezmoi.GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_dir/file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .dir/file\n"), + ), + }, + }, + { + name: "empty", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".empty": "", + }, + }, + args: []string{"~/.empty"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/empty_dot_empty", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "empty_with_--empty", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".empty": "", + }, + }, + args: []string{"--empty", "~/.empty"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/empty_dot_empty", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContents(nil), + ), + }, + }, + { + name: "executable_unix", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".executable": &vfst.File{ + Perm: 0o777, + Contents: []byte("#!/bin/sh\n"), + }, + }, + }, + args: []string{"~/.executable"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/executable_dot_executable", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("#!/bin/sh\n"), + ), + }, + }, + { + name: "file", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".file": "# contents of .file\n", + }, + }, + args: []string{"~/.file"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .file\n"), + ), + }, + }, + { + name: "symlink", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".symlink": &vfst.Symlink{ + Target: ".dir/subdir/file", + }, + }, + }, + args: []string{"~/.symlink"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/symlink_dot_symlink", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString(".dir/subdir/file\n"), + ), + }, + }, + { + name: "symlink_with_--follow", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".file": "# contents of .file\n", + ".symlink": &vfst.Symlink{ + Target: ".file", + }, + }, + }, + args: []string{"--follow", "~/.symlink"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_symlink", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .file\n"), + ), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.SkipUnlessGOOS(t, tc.name) + chezmoitest.WithTestFS(t, tc.root, func(fs vfs.FS) { + require.NoError(t, newTestConfig(t, fs).execute(append([]string{"add"}, tc.args...))) + vfst.RunTests(t, fs, "", tc.tests...) + }) + }) + } +} diff --git a/chezmoi2/cmd/applycmd.go b/chezmoi2/cmd/applycmd.go new file mode 100644 index 000000000000..07901cd0fb4c --- /dev/null +++ b/chezmoi2/cmd/applycmd.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type applyCmdConfig struct { + ignoreEncrypted bool + include *chezmoi.IncludeSet + recursive bool +} + +func (c *Config) newApplyCmd() *cobra.Command { + applyCmd := &cobra.Command{ + Use: "apply [target]...", + Short: "Update the destination directory to match the target state", + Long: mustLongHelp("apply"), + Example: example("apply"), + RunE: c.runApplyCmd, + Annotations: map[string]string{ + modifiesDestinationDirectory: "true", + persistentStateMode: persistentStateModeReadWrite, + }, + } + + flags := applyCmd.Flags() + flags.BoolVar(&c.apply.ignoreEncrypted, "ignore-encrypted", c.apply.ignoreEncrypted, "ignore encrypted files") + flags.VarP(c.apply.include, "include", "i", "include entry types") + flags.BoolVarP(&c.apply.recursive, "recursive", "r", c.apply.recursive, "recursive") + + return applyCmd +} + +func (c *Config) runApplyCmd(cmd *cobra.Command, args []string) error { + return c.applyArgs(c.destSystem, c.destDirAbsPath, args, applyArgsOptions{ + ignoreEncrypted: c.apply.ignoreEncrypted, + include: c.apply.include, + recursive: c.apply.recursive, + umask: c.Umask.FileMode(), + preApplyFunc: c.defaultPreApplyFunc, + }) +} diff --git a/chezmoi2/cmd/applycmd_test.go b/chezmoi2/cmd/applycmd_test.go new file mode 100644 index 000000000000..831265db6270 --- /dev/null +++ b/chezmoi2/cmd/applycmd_test.go @@ -0,0 +1,214 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestApplyCmd(t *testing.T) { + for _, tc := range []struct { + name string + extraRoot interface{} + args []string + tests []interface{} + }{ + { + name: "all", + tests: []interface{}{ + vfst.TestPath("/home/user/.absent", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^chezmoi.GetUmask()), + ), + vfst.TestPath("/home/user/.dir/file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .dir/file\n"), + ), + vfst.TestPath("/home/user/.dir/subdir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^chezmoi.GetUmask()), + ), + vfst.TestPath("/home/user/.dir/subdir/file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .dir/subdir/file\n"), + ), + vfst.TestPath("/home/user/.empty", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContents(nil), + ), + vfst.TestPath("/home/user/.executable", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o777&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .executable\n"), + ), + vfst.TestPath("/home/user/.exists", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .exists\n"), + ), + vfst.TestPath("/home/user/.file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .file\n"), + ), + vfst.TestPath("/home/user/.private", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o600&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .private\n"), + ), + vfst.TestPath("/home/user/.symlink", + vfst.TestModeType(os.ModeSymlink), + vfst.TestSymlinkTarget(filepath.FromSlash(".dir/subdir/file")), + ), + vfst.TestPath("/home/user/.template", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("key = value\n"), + ), + }, + }, + { + name: "all_with_--dry-run", + args: []string{"--dry-run"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.absent", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.dir", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.empty", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.executable", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.exists", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.file", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.private", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.symlink", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.template", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "dir", + args: []string{"~/.dir"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^chezmoi.GetUmask()), + ), + vfst.TestPath("/home/user/.dir/file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .dir/file\n"), + ), + vfst.TestPath("/home/user/.dir/subdir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^chezmoi.GetUmask()), + ), + vfst.TestPath("/home/user/.dir/subdir/file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .dir/subdir/file\n"), + ), + }, + }, + { + name: "dir_with_--recursive=false", + args: []string{"~/.dir", "--recursive=false"}, + tests: []interface{}{ + vfst.TestPath("/home/user/.dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^chezmoi.GetUmask()), + ), + vfst.TestPath("/home/user/.dir/file", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.dir/subdir", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "exists", + args: []string{"~/.exists"}, + extraRoot: map[string]interface{}{ + "/home/user/.exists": "# existing contents of .exists\n", + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.exists", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# existing contents of .exists\n"), + ), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".config": map[string]interface{}{ + "chezmoi": map[string]interface{}{ + "chezmoi.toml": chezmoitest.JoinLines( + `[data]`, + ` variable = "value"`, + ), + }, + }, + ".local": map[string]interface{}{ + "share": map[string]interface{}{ + "chezmoi": map[string]interface{}{ + "dot_absent": "", + "dot_dir": map[string]interface{}{ + "file": "# contents of .dir/file\n", + "subdir": map[string]interface{}{ + "file": "# contents of .dir/subdir/file\n", + }, + }, + "empty_dot_empty": "", + "executable_dot_executable": "# contents of .executable\n", + "exists_dot_exists": "# contents of .exists\n", + "dot_file": "# contents of .file\n", + "private_dot_private": "# contents of .private\n", + "symlink_dot_symlink": ".dir/subdir/file\n", + "dot_template.tmpl": chezmoitest.JoinLines( + `key = {{ "value" }}`, + ), + }, + }, + }, + }, + }, func(fs vfs.FS) { + if tc.extraRoot != nil { + require.NoError(t, vfst.NewBuilder().Build(fs, tc.extraRoot)) + } + require.NoError(t, newTestConfig(t, fs).execute(append([]string{"apply"}, tc.args...))) + vfst.RunTests(t, fs, "", tc.tests) + }) + }) + } +} diff --git a/chezmoi2/cmd/archivecmd.go b/chezmoi2/cmd/archivecmd.go new file mode 100644 index 000000000000..f31e31685637 --- /dev/null +++ b/chezmoi2/cmd/archivecmd.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "os" + "os/user" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type archiveCmdConfig struct { + format string + gzip bool + include *chezmoi.IncludeSet + recursive bool +} + +func (c *Config) newArchiveCmd() *cobra.Command { + archiveCmd := &cobra.Command{ + Use: "archive [target]...", + Short: "Generate a tar archive of the target state", + Long: mustLongHelp("archive"), + Example: example("archive"), + RunE: c.runArchiveCmd, + Annotations: map[string]string{ + persistentStateMode: persistentStateModeEmpty, + }, + } + + flags := archiveCmd.Flags() + flags.StringVar(&c.archive.format, "format", "tar", "format (tar or zip)") + flags.BoolVarP(&c.archive.gzip, "gzip", "z", c.archive.gzip, "compress the output with gzip") + flags.VarP(c.archive.include, "include", "i", "include entry types") + flags.BoolVarP(&c.archive.recursive, "recursive", "r", c.archive.recursive, "recursive") + + return archiveCmd +} + +func (c *Config) runArchiveCmd(cmd *cobra.Command, args []string) error { + output := strings.Builder{} + var archiveSystem interface { + chezmoi.System + Close() error + } + switch c.archive.format { + case "tar": + archiveSystem = chezmoi.NewTARWriterSystem(&output, tarHeaderTemplate()) + case "zip": + archiveSystem = chezmoi.NewZIPWriterSystem(&output, time.Now().UTC()) + default: + return fmt.Errorf("%s: invalid format", c.archive.format) + } + if err := c.applyArgs(archiveSystem, "", args, applyArgsOptions{ + include: c.archive.include, + recursive: c.archive.recursive, + umask: os.ModePerm, + }); err != nil { + return err + } + if err := archiveSystem.Close(); err != nil { + return err + } + + if c.archive.format == "zip" || !c.archive.gzip { + return c.writeOutputString(output.String()) + } + + gzippedArchive := strings.Builder{} + w := gzip.NewWriter(&gzippedArchive) + if _, err := w.Write([]byte(output.String())); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + return c.writeOutputString(gzippedArchive.String()) +} + +// tarHeaderTemplate returns a tar.Header template populated with the current +// user and time. +func tarHeaderTemplate() tar.Header { + // Attempt to lookup the current user. Ignore errors because the default + // zero values are reasonable. + var ( + uid int + gid int + uname string + gname string + ) + if currentUser, err := user.Current(); err == nil { + uid, _ = strconv.Atoi(currentUser.Uid) + gid, _ = strconv.Atoi(currentUser.Gid) + uname = currentUser.Username + if group, err := user.LookupGroupId(currentUser.Gid); err == nil { + gname = group.Name + } + } + + now := time.Now().UTC() + return tar.Header{ + Uid: uid, + Gid: gid, + Uname: uname, + Gname: gname, + ModTime: now, + AccessTime: now, + ChangeTime: now, + } +} diff --git a/chezmoi2/cmd/bitwardentemplatefuncs.go b/chezmoi2/cmd/bitwardentemplatefuncs.go new file mode 100644 index 000000000000..dea43e7eaf74 --- /dev/null +++ b/chezmoi2/cmd/bitwardentemplatefuncs.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type bitwardenConfig struct { + Command string + outputCache map[string][]byte +} + +func (c *Config) bitwardenFieldsTemplateFunc(args ...string) map[string]interface{} { + output := c.bitwardenOutput(args) + var data struct { + Fields []map[string]interface{} `json:"fields"` + } + if err := json.Unmarshal(output, &data); err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w\n%s", c.Bitwarden.Command, chezmoi.ShellQuoteArgs(args), err, output)) + return nil + } + result := make(map[string]interface{}) + for _, field := range data.Fields { + if name, ok := field["name"].(string); ok { + result[name] = field + } + } + return result +} + +func (c *Config) bitwardenOutput(args []string) []byte { + key := strings.Join(args, "\x00") + if data, ok := c.Bitwarden.outputCache[key]; ok { + return data + } + + name := c.Bitwarden.Command + args = append([]string{"get"}, args...) + cmd := exec.Command(name, args...) + cmd.Stdin = c.stdin + cmd.Stderr = c.stderr + output, err := c.baseSystem.IdempotentCmdOutput(cmd) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w\n%s", name, chezmoi.ShellQuoteArgs(args), err, output)) + return nil + } + + if c.Bitwarden.outputCache == nil { + c.Bitwarden.outputCache = make(map[string][]byte) + } + c.Bitwarden.outputCache[key] = output + return output +} + +func (c *Config) bitwardenTemplateFunc(args ...string) map[string]interface{} { + output := c.bitwardenOutput(args) + var data map[string]interface{} + if err := json.Unmarshal(output, &data); err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w\n%s", c.Bitwarden.Command, chezmoi.ShellQuoteArgs(args), err, output)) + return nil + } + return data +} diff --git a/chezmoi2/cmd/catcmd.go b/chezmoi2/cmd/catcmd.go new file mode 100644 index 000000000000..ee265bac4454 --- /dev/null +++ b/chezmoi2/cmd/catcmd.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +func (c *Config) newCatCmd() *cobra.Command { + catCmd := &cobra.Command{ + Use: "cat target...", + Short: "Print the target contents of a file or symlink", + Long: mustLongHelp("cat"), + Example: example("cat"), + Args: cobra.MinimumNArgs(1), + RunE: c.makeRunEWithSourceState(c.runCatCmd), + } + + return catCmd +} + +func (c *Config) runCatCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ + mustBeInSourceState: true, + }) + if err != nil { + return err + } + + sb := strings.Builder{} + for _, targetRelPath := range targetRelPaths { + targetStateEntry, err := sourceState.MustEntry(targetRelPath).TargetStateEntry() + if err != nil { + return fmt.Errorf("%s: %w", targetRelPath, err) + } + switch targetStateEntry := targetStateEntry.(type) { + case *chezmoi.TargetStateFile: + contents, err := targetStateEntry.Contents() + if err != nil { + return fmt.Errorf("%s: %w", targetRelPath, err) + } + sb.Write(contents) + case *chezmoi.TargetStatePresent: + contents, err := targetStateEntry.Contents() + if err != nil { + return fmt.Errorf("%s: %w", targetRelPath, err) + } + sb.Write(contents) + case *chezmoi.TargetStateSymlink: + linkname, err := targetStateEntry.Linkname() + if err != nil { + return fmt.Errorf("%s: %w", targetRelPath, err) + } + sb.WriteString(linkname + "\n") + default: + return fmt.Errorf("%s: not a file or symlink", targetRelPath) + } + } + return c.writeOutputString(sb.String()) +} diff --git a/chezmoi2/cmd/cdcmd.go b/chezmoi2/cmd/cdcmd.go new file mode 100644 index 000000000000..9574576464e9 --- /dev/null +++ b/chezmoi2/cmd/cdcmd.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/twpayne/go-shell" +) + +type cdCmdConfig struct { + Command string + Args []string +} + +func (c *Config) newCDCmd() *cobra.Command { + cdCmd := &cobra.Command{ + Use: "cd", + Short: "Launch a shell in the source directory", + Long: mustLongHelp("cd"), + Example: example("cd"), + RunE: c.runCDCmd, + Args: cobra.NoArgs, + Annotations: map[string]string{ + doesNotRequireValidConfig: "true", + requiresSourceDirectory: "true", + runsCommands: "true", + }, + } + + return cdCmd +} + +func (c *Config) runCDCmd(cmd *cobra.Command, args []string) error { + shellCommand := c.CD.Command + if shellCommand == "" { + shellCommand, _ = shell.CurrentUserShell() + } + return c.run(c.sourceDirAbsPath, shellCommand, c.CD.Args) +} diff --git a/chezmoi2/cmd/chattrcmd.go b/chezmoi2/cmd/chattrcmd.go new file mode 100644 index 000000000000..b472d538d68b --- /dev/null +++ b/chezmoi2/cmd/chattrcmd.go @@ -0,0 +1,269 @@ +package cmd + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type boolModifier int + +const ( + boolModifierSet boolModifier = 1 + boolModifierLeaveUnchanged boolModifier = 0 + boolModifierClear boolModifier = -1 +) + +type orderModifier int + +const ( + orderModifierSetFirst orderModifier = -2 + orderModifierClearFirst orderModifier = -1 + orderModifierLeaveUnchanged orderModifier = 0 + orderModifierClearLast orderModifier = 1 + orderModifierSetLast orderModifier = 2 +) + +type attrModifier struct { + empty boolModifier + encrypted boolModifier + exact boolModifier + executable boolModifier + once boolModifier + order orderModifier + private boolModifier + template boolModifier +} + +func (c *Config) newChattrCmd() *cobra.Command { + attrs := []string{ + "empty", "e", + "encrypted", + "exact", + "executable", "x", + "first", "f", + "last", "l", + "once", "o", + "private", "p", + "template", "t", + } + validArgs := make([]string, 0, 4*len(attrs)) + for _, attribute := range attrs { + validArgs = append(validArgs, attribute, "-"+attribute, "+"+attribute, "no"+attribute) + } + + chattrCmd := &cobra.Command{ + Use: "chattr attributes target...", + Short: "Change the attributes of a target in the source state", + Long: mustLongHelp("chattr"), + Example: example("chattr"), + Args: cobra.MinimumNArgs(2), + ValidArgs: validArgs, + RunE: c.makeRunEWithSourceState(c.runChattrCmd), + Annotations: map[string]string{ + modifiesSourceDirectory: "true", + }, + } + + return chattrCmd +} + +func (c *Config) runChattrCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + // LATER should the core functionality of chattr move to chezmoi.SourceState? + + am, err := parseAttrModifier(args[0]) + if err != nil { + return err + } + + targetRelPaths, err := c.targetRelPaths(sourceState, args[1:], targetRelPathsOptions{ + mustBeInSourceState: true, + }) + if err != nil { + return err + } + + // Sort targets in reverse so we update children before their parent + // directories. + sort.Sort(sort.Reverse(targetRelPaths)) + + for _, targetRelPath := range targetRelPaths { + sourceStateEntry := sourceState.MustEntry(targetRelPath) + sourceRelPath := sourceStateEntry.SourceRelPath() + parentSourceRelPath, fileSourceRelPath := sourceRelPath.Split() + parentRelPath := parentSourceRelPath.RelPath() + fileRelPath := fileSourceRelPath.RelPath() + switch sourceStateEntry := sourceStateEntry.(type) { + case *chezmoi.SourceStateDir: + if newBaseNameRelPath := chezmoi.RelPath(am.modifyDirAttr(sourceStateEntry.Attr).SourceName()); newBaseNameRelPath != fileRelPath { + oldSourceAbsPath := c.sourceDirAbsPath.Join(parentRelPath, fileRelPath) + newSourceAbsPath := c.sourceDirAbsPath.Join(parentRelPath, newBaseNameRelPath) + if err := c.sourceSystem.Rename(oldSourceAbsPath, newSourceAbsPath); err != nil { + return err + } + } + case *chezmoi.SourceStateFile: + // FIXME encrypted attribute changes + // FIXME when changing encrypted attribute add new file before removing old one + if newBaseNameRelPath := chezmoi.RelPath(am.modifyFileAttr(sourceStateEntry.Attr).SourceName()); newBaseNameRelPath != fileRelPath { + oldSourceAbsPath := c.sourceDirAbsPath.Join(parentRelPath, fileRelPath) + newSourceAbsPath := c.sourceDirAbsPath.Join(parentRelPath, newBaseNameRelPath) + if err := c.sourceSystem.Rename(oldSourceAbsPath, newSourceAbsPath); err != nil { + return err + } + } + } + } + + return nil +} + +func (m boolModifier) modify(b bool) bool { + switch m { + case boolModifierSet: + return true + case boolModifierLeaveUnchanged: + return b + case boolModifierClear: + return false + default: + panic(fmt.Sprintf("%d: unknown bool modifier", m)) + } +} + +func (m orderModifier) modify(order int) int { + switch m { + case orderModifierSetFirst: + return -1 + case orderModifierClearFirst: + if order < 0 { + return 0 + } + return order + case orderModifierLeaveUnchanged: + return order + case orderModifierClearLast: + if order > 0 { + return 0 + } + return order + case orderModifierSetLast: + return 1 + default: + panic(fmt.Sprintf("%d: unknown order modifier", m)) + } +} + +func parseAttrModifier(s string) (*attrModifier, error) { + am := &attrModifier{} + for _, modifierStr := range strings.Split(s, ",") { + modifierStr = strings.TrimSpace(modifierStr) + if modifierStr == "" { + continue + } + var bm boolModifier + var attribute string + switch { + case modifierStr[0] == '-': + bm = boolModifierClear + attribute = modifierStr[1:] + case modifierStr[0] == '+': + bm = boolModifierSet + attribute = modifierStr[1:] + case strings.HasPrefix(modifierStr, "no"): + bm = boolModifierClear + attribute = modifierStr[2:] + default: + bm = boolModifierSet + attribute = modifierStr + } + switch attribute { + case "empty", "e": + am.empty = bm + case "encrypted": + am.encrypted = bm + case "exact": + am.exact = bm + case "executable", "x": + am.executable = bm + case "first", "f": + switch bm { + case boolModifierClear: + am.order = orderModifierClearFirst + case boolModifierLeaveUnchanged: + am.order = orderModifierLeaveUnchanged + case boolModifierSet: + am.order = orderModifierSetFirst + } + case "last", "l": + switch bm { + case boolModifierClear: + am.order = orderModifierClearLast + case boolModifierLeaveUnchanged: + am.order = orderModifierLeaveUnchanged + case boolModifierSet: + am.order = orderModifierSetLast + } + case "once", "o": + am.once = bm + case "private", "p": + am.private = bm + case "template", "t": + am.template = bm + default: + return nil, fmt.Errorf("%s: unknown attribute", attribute) + } + } + return am, nil +} + +func (am *attrModifier) modifyDirAttr(dirAttr chezmoi.DirAttr) chezmoi.DirAttr { + return chezmoi.DirAttr{ + TargetName: dirAttr.TargetName, + Exact: am.exact.modify(dirAttr.Exact), + Private: am.private.modify(dirAttr.Private), + } +} + +func (am *attrModifier) modifyFileAttr(fileAttr chezmoi.FileAttr) chezmoi.FileAttr { + switch fileAttr.Type { + case chezmoi.SourceFileTypeFile: + return chezmoi.FileAttr{ + TargetName: fileAttr.TargetName, + Type: chezmoi.SourceFileTypeFile, + Empty: am.empty.modify(fileAttr.Empty), + Encrypted: am.encrypted.modify(fileAttr.Encrypted), + Executable: am.executable.modify(fileAttr.Executable), + Private: am.private.modify(fileAttr.Private), + Template: am.template.modify(fileAttr.Template), + } + case chezmoi.SourceFileTypePresent: + return chezmoi.FileAttr{ + TargetName: fileAttr.TargetName, + Type: chezmoi.SourceFileTypePresent, + Encrypted: am.encrypted.modify(fileAttr.Encrypted), + Executable: am.executable.modify(fileAttr.Executable), + Private: am.private.modify(fileAttr.Private), + Template: am.template.modify(fileAttr.Template), + } + case chezmoi.SourceFileTypeScript: + return chezmoi.FileAttr{ + TargetName: fileAttr.TargetName, + Type: chezmoi.SourceFileTypeScript, + Once: am.once.modify(fileAttr.Once), + Order: am.order.modify(fileAttr.Order), + } + case chezmoi.SourceFileTypeSymlink: + return chezmoi.FileAttr{ + TargetName: fileAttr.TargetName, + Type: chezmoi.SourceFileTypeSymlink, + Template: am.template.modify(fileAttr.Template), + } + default: + panic(fmt.Sprintf("%d: unknown source file type", fileAttr.Type)) + } +} diff --git a/chezmoi2/cmd/chattrcmd_test.go b/chezmoi2/cmd/chattrcmd_test.go new file mode 100644 index 000000000000..a30cf67e3027 --- /dev/null +++ b/chezmoi2/cmd/chattrcmd_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseAttrModifier(t *testing.T) { + for _, tc := range []struct { + s string + expected *attrModifier + expectedErr bool + }{ + { + s: "empty", + expected: &attrModifier{ + empty: boolModifierSet, + }, + }, + { + s: "+empty", + expected: &attrModifier{ + empty: boolModifierSet, + }, + }, + { + s: "-empty", + expected: &attrModifier{ + empty: boolModifierClear, + }, + }, + { + s: "noempty", + expected: &attrModifier{ + empty: boolModifierClear, + }, + }, + { + s: "e", + expected: &attrModifier{ + empty: boolModifierSet, + }, + }, + { + s: "encrypted", + expected: &attrModifier{ + encrypted: boolModifierSet, + }, + }, + { + s: "executable", + expected: &attrModifier{ + executable: boolModifierSet, + }, + }, + { + s: "x", + expected: &attrModifier{ + executable: boolModifierSet, + }, + }, + { + s: "f", + expected: &attrModifier{ + order: orderModifierSetFirst, + }, + }, + { + s: "-f", + expected: &attrModifier{ + order: orderModifierClearFirst, + }, + }, + { + s: "last", + expected: &attrModifier{ + order: orderModifierSetLast, + }, + }, + { + s: "nolast", + expected: &attrModifier{ + order: orderModifierClearLast, + }, + }, + { + s: "once", + expected: &attrModifier{ + once: boolModifierSet, + }, + }, + { + s: "private", + expected: &attrModifier{ + private: boolModifierSet, + }, + }, + { + s: "p", + expected: &attrModifier{ + private: boolModifierSet, + }, + }, + { + s: "template", + expected: &attrModifier{ + template: boolModifierSet, + }, + }, + { + s: "t", + expected: &attrModifier{ + template: boolModifierSet, + }, + }, + { + s: "empty,+executable,noprivate,-t", + expected: &attrModifier{ + empty: boolModifierSet, + executable: boolModifierSet, + private: boolModifierClear, + template: boolModifierClear, + }, + }, + { + s: " empty , -private, notemplate ", + expected: &attrModifier{ + empty: boolModifierSet, + private: boolModifierClear, + template: boolModifierClear, + }, + }, + { + s: "p,,-t", + expected: &attrModifier{ + private: boolModifierSet, + template: boolModifierClear, + }, + }, + { + s: "unknown", + expectedErr: true, + }, + } { + t.Run(tc.s, func(t *testing.T) { + actual, err := parseAttrModifier(tc.s) + if tc.expectedErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, actual) + } + }) + } +} diff --git a/chezmoi2/cmd/cmd.go b/chezmoi2/cmd/cmd.go new file mode 100644 index 000000000000..cf85c28a19bf --- /dev/null +++ b/chezmoi2/cmd/cmd.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" +) + +// Command annotations. +const ( + doesNotRequireValidConfig = "chezmoi_does_not_require_valid_config" + modifiesConfigFile = "chezmoi_modifies_config_file" + modifiesDestinationDirectory = "chezmoi_modifies_destination_directory" + modifiesSourceDirectory = "chezmoi_modifies_source_directory" + persistentStateMode = "chezmoi_persistent_state_mode" + requiresConfigDirectory = "chezmoi_requires_config_directory" + requiresSourceDirectory = "chezmoi_requires_source_directory" + runsCommands = "chezmoi_runs_commands" +) + +// Persistent state modes. +const ( + persistentStateModeEmpty = "empty" + persistentStateModeReadOnly = "read-only" + persistentStateModeReadMockWrite = "read-mock-write" + persistentStateModeReadWrite = "read-write" +) + +var noArgs = []string(nil) + +// An ErrExitCode indicates the the main program should exit with the given +// code. +type ErrExitCode int + +func (e ErrExitCode) Error() string { return "" } + +// A VersionInfo contains a version. +type VersionInfo struct { + Version string + Commit string + Date string + BuiltBy string +} + +// Main runs chezmoi and returns an exit code. +func Main(versionInfo VersionInfo, args []string) int { + if err := runMain(versionInfo, args); err != nil { + if s := err.Error(); s != "" { + fmt.Fprintf(os.Stderr, "chezmoi: %s\n", s) + } + errExitCode := ErrExitCode(1) + _ = errors.As(err, &errExitCode) + return int(errExitCode) + } + return 0 +} + +func asset(name string) ([]byte, error) { + asset, ok := assets[name] + if !ok { + return nil, fmt.Errorf("%s: not found", name) + } + return asset, nil +} + +func boolAnnotation(cmd *cobra.Command, key string) bool { + value, ok := cmd.Annotations[key] + if !ok { + return false + } + boolValue, err := strconv.ParseBool(value) + if err != nil { + panic(err) + } + return boolValue +} + +func example(command string) string { + return helps[command].example +} + +func mustLongHelp(command string) string { + help, ok := helps[command] + if !ok { + panic(fmt.Sprintf("%s: no long help", command)) + } + return help.long +} + +func runMain(versionInfo VersionInfo, args []string) error { + config, err := newConfig( + withVersionInfo(versionInfo), + ) + if err != nil { + return err + } + return config.execute(args) +} diff --git a/chezmoi2/cmd/cmd_test.go b/chezmoi2/cmd/cmd_test.go new file mode 100644 index 000000000000..2c7dc98aa0bd --- /dev/null +++ b/chezmoi2/cmd/cmd_test.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMustGetLongHelpPanics(t *testing.T) { + assert.Panics(t, func() { + mustLongHelp("non-existent-command") + }) +} diff --git a/chezmoi2/cmd/completioncmd.go b/chezmoi2/cmd/completioncmd.go new file mode 100644 index 000000000000..908a19db98f8 --- /dev/null +++ b/chezmoi2/cmd/completioncmd.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +func (c *Config) newCompletionCmd() *cobra.Command { + completionCmd := &cobra.Command{ + Use: "completion shell", + Short: "Generate shell completion code", + Args: cobra.ExactArgs(1), + ValidArgs: []string{"bash", "fish", "powershell", "zsh"}, + Long: mustLongHelp("completion"), + Example: example("completion"), + RunE: c.runCompletionCmd, + Annotations: map[string]string{ + doesNotRequireValidConfig: "true", + }, + } + + return completionCmd +} + +func (c *Config) runCompletionCmd(cmd *cobra.Command, args []string) error { + var sb strings.Builder + switch args[0] { + case "bash": + if err := cmd.Root().GenBashCompletion(&sb); err != nil { + return err + } + case "fish": + if err := cmd.Root().GenFishCompletion(&sb, true); err != nil { + return err + } + case "powershell": + if err := cmd.Root().GenPowerShellCompletion(&sb); err != nil { + return err + } + case "zsh": + if err := cmd.Root().GenZshCompletion(&sb); err != nil { + return err + } + default: + return fmt.Errorf("%s: unsupported shell", args[0]) + } + return c.writeOutputString(sb.String()) +} diff --git a/chezmoi2/cmd/config.go b/chezmoi2/cmd/config.go new file mode 100644 index 000000000000..921c3d465582 --- /dev/null +++ b/chezmoi2/cmd/config.go @@ -0,0 +1,1220 @@ +package cmd + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "regexp" + "runtime" + "sort" + "strings" + "text/template" + "time" + + "github.com/Masterminds/sprig/v3" + "github.com/coreos/go-semver/semver" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/twpayne/go-vfs" + vfsafero "github.com/twpayne/go-vfsafero" + "github.com/twpayne/go-xdg/v3" + "golang.org/x/term" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" + "github.com/twpayne/chezmoi/internal/git" +) + +type purgeOptions struct { + binary bool +} + +type templateConfig struct { + Options []string `mapstructure:"options"` +} + +// A Config represents a configuration. +type Config struct { + version *semver.Version + versionInfo VersionInfo + versionStr string + + bds *xdg.BaseDirectorySpecification + + fs vfs.FS + configFile string + baseSystem chezmoi.System + sourceSystem chezmoi.System + destSystem chezmoi.System + persistentState chezmoi.PersistentState + color bool + + // Global configuration, settable in the config file. + HomeDir string `mapstructure:"homeDir"` + SourceDir string `mapstructure:"sourceDir"` + DestDir string `mapstructure:"destDir"` + Umask fileMode `mapstructure:"umask"` + Format string `mapstructure:"format"` + Remove bool `mapstructure:"remove"` + Color string `mapstructure:"color"` + Data map[string]interface{} `mapstructure:"data"` + Template templateConfig `mapstructure:"template"` + UseBuiltinGit string `mapstructure:"useBuiltinGit"` + + // Global configuration, not settable in the config file. + debug bool + dryRun bool + force bool + keepGoing bool + outputStr string + verbose bool + templateFuncs template.FuncMap + + // Password manager configurations, settable in the config file. + Bitwarden bitwardenConfig `mapstructure:"bitwarden"` + Gopass gopassConfig `mapstructure:"gopass"` + Keepassxc keepassxcConfig `mapstructure:"keepassxc"` + Lastpass lastpassConfig `mapstructure:"lastpass"` + Onepassword onepasswordConfig `mapstructure:"onepassword"` + Pass passConfig `mapstructure:"pass"` + Secret secretConfig `mapstructure:"secret"` + Vault vaultConfig `mapstructure:"vault"` + + // Encryption configurations, settable in the config file. + Encryption string `mapstructure:"encryption"` + AGE chezmoi.AGEEncryption `mapstructure:"age"` + GPG chezmoi.GPGEncryption `mapstructure:"gpg"` + + // Password manager data. + gitHub gitHubData + keyring keyringData + + // Command configurations, settable in the config file. + CD cdCmdConfig `mapstructure:"cd"` + Diff diffCmdConfig `mapstructure:"diff"` + Edit editCmdConfig `mapstructure:"edit"` + Git gitCmdConfig `mapstructure:"git"` + Merge mergeCmdConfig `mapstructure:"merge"` + + // Command configurations, not settable in the config file. + add addCmdConfig + apply applyCmdConfig + archive archiveCmdConfig + dump dumpCmdConfig + executeTemplate executeTemplateCmdConfig + _import importCmdConfig + init initCmdConfig + managed managedCmdConfig + purge purgeCmdConfig + status statusCmdConfig + update updateCmdConfig + verify verifyCmdConfig + + // Computed configuration. + configFileAbsPath chezmoi.AbsPath + homeDirAbsPath chezmoi.AbsPath + sourceDirAbsPath chezmoi.AbsPath + destDirAbsPath chezmoi.AbsPath + encryption chezmoi.Encryption + + stdin io.Reader + stdout io.Writer + stderr io.Writer + tty io.ReadWriter + ttyReader *bufio.Reader + ttyWriter io.Writer + + ioregData ioregData +} + +// A configOption sets and option on a Config. +type configOption func(*Config) error + +var ( + persistentStateFilename = chezmoi.RelPath("chezmoistate.boltdb") + commitMessageTemplateAsset = "assets/templates/COMMIT_MESSAGE.tmpl" + + identifierRx = regexp.MustCompile(`\A[\pL_][\pL\p{Nd}_]*\z`) + whitespaceRx = regexp.MustCompile(`\s+`) + + assets = make(map[string][]byte) +) + +// newConfig creates a new Config with the given options. +func newConfig(options ...configOption) (*Config, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + normalizedHomeDir, err := chezmoi.NormalizePath(homeDir) + if err != nil { + return nil, err + } + + bds, err := xdg.NewBaseDirectorySpecification() + if err != nil { + return nil, err + } + + c := &Config{ + bds: bds, + fs: vfs.OSFS, + HomeDir: homeDir, + DestDir: homeDir, + Umask: fileMode(chezmoi.GetUmask()), + Color: "auto", + Format: "json", + Diff: diffCmdConfig{ + include: chezmoi.NewIncludeSet(chezmoi.IncludeAll &^ chezmoi.IncludeScripts), + }, + Edit: editCmdConfig{ + include: chezmoi.NewIncludeSet(chezmoi.IncludeDirs | chezmoi.IncludeFiles | chezmoi.IncludeSymlinks), + }, + Git: gitCmdConfig{ + Command: "git", + }, + Merge: mergeCmdConfig{ + Command: "vimdiff", + }, + Template: templateConfig{ + Options: chezmoi.DefaultTemplateOptions, + }, + templateFuncs: sprig.TxtFuncMap(), + Bitwarden: bitwardenConfig{ + Command: "bw", + }, + Gopass: gopassConfig{ + Command: "gopass", + }, + Keepassxc: keepassxcConfig{ + Command: "keepassxc-cli", + }, + Lastpass: lastpassConfig{ + Command: "lpass", + }, + Onepassword: onepasswordConfig{ + Command: "op", + }, + Pass: passConfig{ + Command: "pass", + }, + Vault: vaultConfig{ + Command: "vault", + }, + AGE: chezmoi.AGEEncryption{ + Command: "age", + }, + GPG: chezmoi.GPGEncryption{ + Command: "gpg", + }, + add: addCmdConfig{ + include: chezmoi.NewIncludeSet(chezmoi.IncludeAll), + recursive: true, + }, + apply: applyCmdConfig{ + include: chezmoi.NewIncludeSet(chezmoi.IncludeAll), + recursive: true, + }, + archive: archiveCmdConfig{ + include: chezmoi.NewIncludeSet(chezmoi.IncludeAll), + recursive: true, + }, + dump: dumpCmdConfig{ + include: chezmoi.NewIncludeSet(chezmoi.IncludeAll), + recursive: true, + }, + _import: importCmdConfig{ + include: chezmoi.NewIncludeSet(chezmoi.IncludeAll), + }, + managed: managedCmdConfig{ + include: chezmoi.NewIncludeSet(chezmoi.IncludeDirs | chezmoi.IncludeFiles | chezmoi.IncludeSymlinks), + }, + status: statusCmdConfig{ + include: chezmoi.NewIncludeSet(chezmoi.IncludeAll), + recursive: true, + }, + update: updateCmdConfig{ + apply: true, + include: chezmoi.NewIncludeSet(chezmoi.IncludeAll), + recursive: true, + }, + verify: verifyCmdConfig{ + include: chezmoi.NewIncludeSet(chezmoi.IncludeAll &^ chezmoi.IncludeScripts), + recursive: true, + }, + + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, + + homeDirAbsPath: normalizedHomeDir, + } + + for key, value := range map[string]interface{}{ + "bitwarden": c.bitwardenTemplateFunc, + "bitwardenFields": c.bitwardenFieldsTemplateFunc, + "gitHubKeys": c.gitHubKeysTemplateFunc, + "gopass": c.gopassTemplateFunc, + "include": c.includeTemplateFunc, + "ioreg": c.ioregTemplateFunc, + "joinPath": c.joinPathTemplateFunc, + "keepassxc": c.keepassxcTemplateFunc, + "keepassxcAttribute": c.keepassxcAttributeTemplateFunc, + "keyring": c.keyringTemplateFunc, + "lastpass": c.lastpassTemplateFunc, + "lastpassRaw": c.lastpassRawTemplateFunc, + "lookPath": c.lookPathTemplateFunc, + "onepassword": c.onepasswordTemplateFunc, + "onepasswordDetailsFields": c.onepasswordDetailsFieldsTemplateFunc, + "onepasswordDocument": c.onepasswordDocumentTemplateFunc, + "pass": c.passTemplateFunc, + "secret": c.secretTemplateFunc, + "secretJSON": c.secretJSONTemplateFunc, + "stat": c.statTemplateFunc, + "vault": c.vaultTemplateFunc, + } { + c.addTemplateFunc(key, value) + } + + for _, option := range options { + if err := option(c); err != nil { + return nil, err + } + } + + c.configFile = string(defaultConfigFile(c.fs, c.bds)) + c.SourceDir = string(defaultSourceDir(c.fs, c.bds)) + + c.homeDirAbsPath, err = chezmoi.NormalizePath(c.HomeDir) + if err != nil { + return nil, err + } + c._import.destination = string(c.homeDirAbsPath) + + return c, nil +} + +func (c *Config) addTemplateFunc(key string, value interface{}) { + if _, ok := c.templateFuncs[key]; ok { + panic(fmt.Sprintf("%s: already defined", key)) + } + c.templateFuncs[key] = value +} + +type applyArgsOptions struct { + ignoreEncrypted bool + include *chezmoi.IncludeSet + recursive bool + umask os.FileMode + preApplyFunc chezmoi.PreApplyFunc +} + +func (c *Config) applyArgs(targetSystem chezmoi.System, targetDirAbsPath chezmoi.AbsPath, args []string, options applyArgsOptions) error { + s, err := c.sourceState() + if err != nil { + return err + } + + applyOptions := chezmoi.ApplyOptions{ + IgnoreEncrypted: options.ignoreEncrypted, + Include: options.include, + PreApplyFunc: options.preApplyFunc, + Umask: options.umask, + } + + var targetRelPaths chezmoi.RelPaths + if len(args) == 0 { + targetRelPaths = s.TargetRelPaths() + } else { + targetRelPaths, err = c.targetRelPaths(s, args, targetRelPathsOptions{ + mustBeInSourceState: true, + recursive: options.recursive, + }) + if err != nil { + return err + } + } + + for _, targetRelPath := range targetRelPaths { + switch err := s.Apply(targetSystem, c.persistentState, targetDirAbsPath, targetRelPath, applyOptions); { + case errors.Is(err, chezmoi.Skip): + continue + case err != nil && c.keepGoing: + c.errorf("%v", err) + case err != nil: + return err + } + } + + return nil +} + +func (c *Config) cmdOutput(dirAbsPath chezmoi.AbsPath, name string, args []string) ([]byte, error) { + cmd := exec.Command(name, args...) + if dirAbsPath != "" { + dirRawAbsPath, err := c.baseSystem.RawPath(dirAbsPath) + if err != nil { + return nil, err + } + cmd.Dir = string(dirRawAbsPath) + } + return c.baseSystem.IdempotentCmdOutput(cmd) +} + +func (c *Config) defaultPreApplyFunc(targetRelPath chezmoi.RelPath, targetEntryState, lastWrittenEntryState, actualEntryState *chezmoi.EntryState) error { + switch { + case c.force: + return nil + case lastWrittenEntryState == nil: + return nil + case lastWrittenEntryState.Equivalent(actualEntryState, c.Umask.FileMode()): + return nil + } + // LATER add merge option + switch choice, err := c.prompt(fmt.Sprintf("%s has changed since chezmoi last wrote it, overwrite", targetRelPath), "ynqa"); { + case err != nil: + return err + case choice == 'a': + c.force = true + return nil + case choice == 'n': + return chezmoi.Skip + case choice == 'q': + return ErrExitCode(1) + default: + return nil + } +} + +func (c *Config) defaultTemplateData() map[string]interface{} { + data := map[string]interface{}{ + "arch": runtime.GOARCH, + "homeDir": c.HomeDir, + "os": runtime.GOOS, + "sourceDir": c.sourceDirAbsPath, + "version": map[string]interface{}{ + "builtBy": c.versionInfo.BuiltBy, + "commit": c.versionInfo.Commit, + "date": c.versionInfo.Date, + "version": c.versionInfo.Version, + }, + } + + // Determine the user's username and group, if possible. + // + // user.Current and user.LookupGroupId in Go's standard library are + // generally unreliable, so work around errors if possible, or ignore them. + // + // If CGO is disabled, then the Go standard library falls back to parsing + // /etc/passwd and /etc/group, which will return incorrect results without + // error if the system uses an alternative password database such as NIS or + // LDAP. + // + // If CGO is enabled then user.Current and user.LookupGroupId will use the + // underlying libc functions, namely getpwuid_r and getgrnam_r. If linked + // with glibc this will return the correct result. If linked with musl then + // they will use musl's implementation which, like Go's non-CGO + // implementation, also only parses /etc/passwd and /etc/group and so also + // returns incorrect results without error if NIS or LDAP are being used. + // + // Since neither the username nor the group are likely widely used in + // templates, leave these variables unset if their values cannot be + // determined. Unset variables will trigger template errors if used, + // alerting the user to the problem and allowing them to find alternative + // solutions. + if currentUser, err := user.Current(); err == nil { + data["username"] = currentUser.Username + if group, err := user.LookupGroupId(currentUser.Gid); err == nil { + data["group"] = group.Name + } else { + log.Debug(). + Str("gid", currentUser.Gid). + Err(err). + Msg("user.LookupGroupId") + } + } else { + log.Debug(). + Err(err). + Msg("user.Current") + user, ok := os.LookupEnv("USER") + if ok { + data["username"] = user + } else { + log.Debug(). + Str("key", "USER"). + Bool("ok", ok). + Msg("os.LookupEnv") + } + } + + if fqdnHostname, err := chezmoi.FQDNHostname(c.fs); err == nil && fqdnHostname != "" { + data["fqdnHostname"] = fqdnHostname + } else { + log.Debug(). + Err(err). + Msg("chezmoi.EtcHostsFQDNHostname") + } + + if hostname, err := os.Hostname(); err == nil { + data["hostname"] = strings.SplitN(hostname, ".", 2)[0] + } else { + log.Debug(). + Err(err). + Msg("os.Hostname") + } + + if kernelInfo, err := chezmoi.KernelInfo(c.fs); err == nil { + data["kernel"] = kernelInfo + } else { + log.Debug(). + Err(err). + Msg("chezmoi.KernelInfo") + } + + if osRelease, err := chezmoi.OSRelease(c.fs); err == nil { + data["osRelease"] = upperSnakeCaseToCamelCaseMap(osRelease) + } else { + log.Debug(). + Err(err). + Msg("chezmoi.OSRelease") + } + + return map[string]interface{}{ + "chezmoi": data, + } +} + +func (c *Config) destAbsPathInfos(sourceState *chezmoi.SourceState, args []string, recursive, follow bool) (map[chezmoi.AbsPath]os.FileInfo, error) { + destAbsPathInfos := make(map[chezmoi.AbsPath]os.FileInfo) + for _, arg := range args { + destAbsPath, err := chezmoi.NewAbsPathFromExtPath(arg, c.homeDirAbsPath) + if err != nil { + return nil, err + } + if _, err := destAbsPath.TrimDirPrefix(c.destDirAbsPath); err != nil { + return nil, err + } + if recursive { + if err := chezmoi.Walk(c.destSystem, destAbsPath, func(destAbsPath chezmoi.AbsPath, info os.FileInfo, err error) error { + if err != nil { + return err + } + if follow && info.Mode()&os.ModeType == os.ModeSymlink { + info, err = c.destSystem.Stat(destAbsPath) + if err != nil { + return err + } + } + return sourceState.AddDestAbsPathInfos(destAbsPathInfos, c.destSystem, destAbsPath, info) + }); err != nil { + return nil, err + } + } else { + var info os.FileInfo + if follow { + info, err = c.destSystem.Stat(destAbsPath) + } else { + info, err = c.destSystem.Lstat(destAbsPath) + } + if err != nil { + return nil, err + } + if err := sourceState.AddDestAbsPathInfos(destAbsPathInfos, c.destSystem, destAbsPath, info); err != nil { + return nil, err + } + } + } + return destAbsPathInfos, nil +} + +func (c *Config) doPurge(purgeOptions *purgeOptions) error { + if c.persistentState != nil { + if err := c.persistentState.Close(); err != nil { + return err + } + } + + absSlashPersistentStateFile := c.persistentStateFile() + absPaths := chezmoi.AbsPaths{ + c.configFileAbsPath.Dir(), + c.configFileAbsPath, + absSlashPersistentStateFile, + c.sourceDirAbsPath, + } + if purgeOptions != nil && purgeOptions.binary { + executable, err := os.Executable() + if err == nil { + absPaths = append(absPaths, chezmoi.AbsPath(executable)) + } + } + + // Remove all paths that exist. + for _, absPath := range absPaths { + switch _, err := c.baseSystem.Stat(absPath); { + case os.IsNotExist(err): + continue + case err != nil: + return err + } + + if !c.force { + switch choice, err := c.prompt(fmt.Sprintf("Remove %s", absPath), "ynqa"); { + case err != nil: + return err + case choice == 'a': + c.force = true + case choice == 'n': + continue + case choice == 'q': + return nil + } + } + + switch err := c.baseSystem.RemoveAll(absPath); { + case os.IsPermission(err): + continue + case err != nil: + return err + } + } + + return nil +} + +// editor returns the path to the user's editor and any extra arguments. +func (c *Config) editor() (string, []string) { + // If the user has set and edit command then use it. + if c.Edit.Command != "" { + return c.Edit.Command, c.Edit.Args + } + + // Prefer $VISUAL over $EDITOR and fallback to vi. + editor := firstNonEmptyString( + os.Getenv("VISUAL"), + os.Getenv("EDITOR"), + "vi", + ) + + // If editor is found, return it. + if path, err := exec.LookPath(editor); err == nil { + return path, nil + } + + // Otherwise, if editor contains spaces, then assume that the first word is + // the editor and the rest are arguments. + components := whitespaceRx.Split(editor, -1) + if len(components) > 1 { + if path, err := exec.LookPath(components[0]); err == nil { + return path, components[1:] + } + } + + // Fallback to editor only. + return editor, nil +} + +func (c *Config) errorf(format string, args ...interface{}) { + fmt.Fprintf(c.stderr, "chezmoi: "+format, args...) +} + +func (c *Config) execute(args []string) error { + rootCmd, err := c.newRootCmd() + if err != nil { + return err + } + rootCmd.SetArgs(args) + return rootCmd.Execute() +} + +func (c *Config) getTTY() (*bufio.Reader, io.Writer, error) { + if c.ttyReader == nil { + // FIXME find out how to get a tty on Windows + if runtime.GOOS == "windows" { + c.ttyReader = bufio.NewReader(c.stdin) + c.ttyWriter = c.stdout + } else { + var err error + c.tty, err = os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return nil, nil, err + } + c.ttyReader = bufio.NewReader(c.tty) + c.ttyWriter = c.tty + } + } + return c.ttyReader, c.ttyWriter, nil +} + +func (c *Config) gitAutoAdd() (*git.Status, error) { + if err := c.run(c.sourceDirAbsPath, c.Git.Command, []string{"add", "."}); err != nil { + return nil, err + } + output, err := c.cmdOutput(c.sourceDirAbsPath, c.Git.Command, []string{"status", "--porcelain=v2"}) + if err != nil { + return nil, err + } + return git.ParseStatusPorcelainV2(output) +} + +func (c *Config) gitAutoCommit(status *git.Status) error { + if status.Empty() { + return nil + } + commitMessageText, err := asset(commitMessageTemplateAsset) + if err != nil { + return err + } + commitMessageTmpl, err := template.New("commit_message").Funcs(c.templateFuncs).Parse(string(commitMessageText)) + if err != nil { + return err + } + commitMessage := strings.Builder{} + if err := commitMessageTmpl.Execute(&commitMessage, status); err != nil { + return err + } + return c.run(c.sourceDirAbsPath, c.Git.Command, []string{"commit", "--message", commitMessage.String()}) +} + +func (c *Config) gitAutoPush(status *git.Status) error { + if status.Empty() { + return nil + } + return c.run(c.sourceDirAbsPath, c.Git.Command, []string{"push"}) +} + +func (c *Config) makeRunEWithSourceState(runE func(*cobra.Command, []string, *chezmoi.SourceState) error) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + sourceState, err := c.sourceState() + if err != nil { + return err + } + return runE(cmd, args, sourceState) + } +} + +func (c *Config) marshal(data interface{}) error { + format, ok := chezmoi.Formats[c.Format] + if !ok { + return fmt.Errorf("%s: unknown format", c.Format) + } + marshaledData, err := format.Marshal(data) + if err != nil { + return err + } + return c.writeOutput(marshaledData) +} + +func (c *Config) newRootCmd() (*cobra.Command, error) { + rootCmd := &cobra.Command{ + Use: "chezmoi", + Short: "Manage your dotfiles across multiple diverse machines, securely", + Version: c.versionStr, + PersistentPreRunE: c.persistentPreRunRootE, + PersistentPostRunE: c.persistentPostRunRootE, + SilenceErrors: true, + SilenceUsage: true, + } + + persistentFlags := rootCmd.PersistentFlags() + + persistentFlags.StringVar(&c.Color, "color", c.Color, "colorize diffs") + persistentFlags.StringVarP(&c.DestDir, "destination", "D", c.DestDir, "destination directory") + persistentFlags.StringVar(&c.Format, "format", c.Format, "format ("+serializationFormatNamesStr()+")") + persistentFlags.BoolVar(&c.Remove, "remove", c.Remove, "remove targets") + persistentFlags.StringVarP(&c.SourceDir, "source", "S", c.SourceDir, "source directory") + persistentFlags.StringVar(&c.UseBuiltinGit, "use-builtin-git", c.UseBuiltinGit, "use builtin git") + for _, key := range []string{ + "color", + "destination", + "format", + "remove", + "source", + } { + if err := viper.BindPFlag(key, persistentFlags.Lookup(key)); err != nil { + return nil, err + } + } + + persistentFlags.StringVarP(&c.configFile, "config", "c", c.configFile, "config file") + persistentFlags.BoolVarP(&c.dryRun, "dry-run", "n", c.dryRun, "dry run") + persistentFlags.BoolVar(&c.force, "force", c.force, "force") + persistentFlags.BoolVarP(&c.keepGoing, "keep-going", "k", c.keepGoing, "keep going as far as possible after an error") + persistentFlags.BoolVarP(&c.verbose, "verbose", "v", c.verbose, "verbose") + persistentFlags.StringVarP(&c.outputStr, "output", "o", c.outputStr, "output file") + persistentFlags.BoolVar(&c.debug, "debug", c.debug, "write debug logs") + + for _, err := range []error{ + rootCmd.MarkPersistentFlagFilename("config"), + rootCmd.MarkPersistentFlagDirname("destination"), + rootCmd.MarkPersistentFlagFilename("output"), + rootCmd.MarkPersistentFlagDirname("source"), + } { + if err != nil { + return nil, err + } + } + + rootCmd.SetHelpCommand(c.newHelpCmd()) + for _, newCmdFunc := range []func() *cobra.Command{ + c.newAddCmd, + c.newApplyCmd, + c.newArchiveCmd, + c.newCatCmd, + c.newCDCmd, + c.newChattrCmd, + c.newCompletionCmd, + c.newDataCmd, + c.newDiffCmd, + c.newDocsCmd, + c.newDoctorCmd, + c.newDumpCmd, + c.newEditCmd, + c.newEditConfigCmd, + c.newExecuteTemplateCmd, + c.newForgetCmd, + c.newGitCmd, + c.newImportCmd, + c.newInitCmd, + c.newManagedCmd, + c.newMergeCmd, + c.newPurgeCmd, + c.newRemoveCmd, + c.newSourcePathCmd, + c.newStateCmd, + c.newStatusCmd, + c.newUnmanagedCmd, + c.newUpdateCmd, + c.newVerifyCmd, + } { + rootCmd.AddCommand(newCmdFunc()) + } + + return rootCmd, nil +} + +func (c *Config) persistentPostRunRootE(cmd *cobra.Command, args []string) error { + if c.persistentState != nil { + if err := c.persistentState.Close(); err != nil { + return err + } + } + + if boolAnnotation(cmd, modifiesConfigFile) { + // Warn the user of any errors reading the config file. + v := viper.New() + v.SetFs(vfsafero.NewAferoFS(c.fs)) + v.SetConfigFile(string(c.configFileAbsPath)) + err := v.ReadInConfig() + if err == nil { + err = v.Unmarshal(&Config{}) + } + if err != nil { + cmd.Printf("warning: %s: %v\n", c.configFileAbsPath, err) + } + } + + if boolAnnotation(cmd, modifiesSourceDirectory) { + var status *git.Status + if c.Git.AutoAdd || c.Git.AutoCommit || c.Git.AutoPush { + var err error + status, err = c.gitAutoAdd() + if err != nil { + return err + } + } + if c.Git.AutoCommit || c.Git.AutoPush { + if err := c.gitAutoCommit(status); err != nil { + return err + } + } + if c.Git.AutoPush { + if err := c.gitAutoPush(status); err != nil { + return err + } + } + } + + return nil +} + +func (c *Config) persistentPreRunRootE(cmd *cobra.Command, args []string) error { + var err error + c.configFileAbsPath, err = chezmoi.NewAbsPathFromExtPath(c.configFile, c.homeDirAbsPath) + if err != nil { + return err + } + + if err := c.readConfig(); err != nil { + if !boolAnnotation(cmd, doesNotRequireValidConfig) { + return fmt.Errorf("invalid config: %s: %w", c.configFile, err) + } + cmd.Printf("warning: %s: %v\n", c.configFile, err) + } + + if c.Color == "" || strings.ToLower(c.Color) == "auto" { + if _, ok := os.LookupEnv("NO_COLOR"); ok { + c.color = false + } else if stdout, ok := c.stdout.(*os.File); ok { + c.color = term.IsTerminal(int(stdout.Fd())) + } else { + c.color = false + } + } else if color, err := parseBool(c.Color); err == nil { + c.color = color + } else if !boolAnnotation(cmd, doesNotRequireValidConfig) { + return fmt.Errorf("%s: invalid color value", c.Color) + } + + if c.color { + if err := enableVirtualTerminalProcessing(c.stdout); err != nil { + return err + } + } + + if c.sourceDirAbsPath, err = chezmoi.NewAbsPathFromExtPath(c.SourceDir, c.homeDirAbsPath); err != nil { + return err + } + if c.destDirAbsPath, err = chezmoi.NewAbsPathFromExtPath(c.DestDir, c.homeDirAbsPath); err != nil { + return err + } + + log.Logger = log.Output(zerolog.NewConsoleWriter( + func(w *zerolog.ConsoleWriter) { + w.Out = c.stderr + w.NoColor = !c.color + w.TimeFormat = time.RFC3339 + }, + )) + if !c.debug { + zerolog.SetGlobalLevel(zerolog.InfoLevel) + } + + c.baseSystem = chezmoi.NewRealSystem(c.fs) + if c.debug { + c.baseSystem = chezmoi.NewDebugSystem(c.baseSystem) + } + + switch { + case cmd.Annotations[persistentStateMode] == persistentStateModeEmpty: + c.persistentState = chezmoi.NewMockPersistentState() + case cmd.Annotations[persistentStateMode] == persistentStateModeReadOnly: + persistentStateFile := c.persistentStateFile() + c.persistentState, err = chezmoi.NewBoltPersistentState(c.baseSystem, persistentStateFile, chezmoi.BoltPersistentStateReadOnly) + if err != nil { + return err + } + case cmd.Annotations[persistentStateMode] == persistentStateModeReadMockWrite: + fallthrough + case cmd.Annotations[persistentStateMode] == persistentStateModeReadWrite && c.dryRun: + persistentStateFile := c.persistentStateFile() + persistentState, err := chezmoi.NewBoltPersistentState(c.baseSystem, persistentStateFile, chezmoi.BoltPersistentStateReadOnly) + if err != nil { + return err + } + dryRunPeristentState := chezmoi.NewMockPersistentState() + if err := persistentState.CopyTo(dryRunPeristentState); err != nil { + return err + } + if err := persistentState.Close(); err != nil { + return err + } + c.persistentState = dryRunPeristentState + case cmd.Annotations[persistentStateMode] == persistentStateModeReadWrite: + persistentStateFile := c.persistentStateFile() + c.persistentState, err = chezmoi.NewBoltPersistentState(c.baseSystem, persistentStateFile, chezmoi.BoltPersistentStateReadWrite) + if err != nil { + return err + } + default: + c.persistentState = nil + } + if c.debug && c.persistentState != nil { + c.persistentState = chezmoi.NewDebugPersistentState(c.persistentState) + } + + c.sourceSystem = c.baseSystem + c.destSystem = c.baseSystem + if !boolAnnotation(cmd, modifiesDestinationDirectory) { + c.destSystem = chezmoi.NewReadOnlySystem(c.destSystem) + } + if !boolAnnotation(cmd, modifiesSourceDirectory) { + c.sourceSystem = chezmoi.NewReadOnlySystem(c.sourceSystem) + } + if c.dryRun { + c.sourceSystem = chezmoi.NewDryRunSystem(c.sourceSystem) + c.destSystem = chezmoi.NewDryRunSystem(c.destSystem) + } + if c.verbose { + c.sourceSystem = chezmoi.NewGitDiffSystem(c.sourceSystem, c.stdout, c.sourceDirAbsPath, c.color) + c.destSystem = chezmoi.NewGitDiffSystem(c.destSystem, c.stdout, c.destDirAbsPath, c.color) + } + + switch c.Encryption { + case "age": + c.encryption = &c.AGE + case "gpg": + c.encryption = &c.GPG + case "": + c.encryption = chezmoi.NoEncryption{} + default: + return fmt.Errorf("%s: unknown encryption", c.Encryption) + } + if c.debug { + c.encryption = chezmoi.NewDebugEncryption(c.encryption) + } + + if boolAnnotation(cmd, requiresConfigDirectory) { + if err := chezmoi.MkdirAll(c.baseSystem, c.configFileAbsPath.Dir(), 0o777); err != nil { + return err + } + } + + if boolAnnotation(cmd, requiresSourceDirectory) { + if err := chezmoi.MkdirAll(c.baseSystem, c.sourceDirAbsPath, 0o777); err != nil { + return err + } + } + + if boolAnnotation(cmd, runsCommands) { + if runtime.GOOS == "linux" && c.bds.RuntimeDir != "" { + // Snap sets the $XDG_RUNTIME_DIR environment variable to + // /run/user/$uid/snap.$snap_name, but does not create this + // directory. Consequently, any spawned processes that need + // $XDG_DATA_DIR will fail. As a work-around, create the directory + // if it does not exist. See + // https://forum.snapcraft.io/t/wayland-dconf-and-xdg-runtime-dir/186/13. + if err := chezmoi.MkdirAll(c.baseSystem, chezmoi.AbsPath(c.bds.RuntimeDir), 0o700); err != nil { + return err + } + } + } + + return nil +} + +func (c *Config) persistentStateFile() chezmoi.AbsPath { + if c.configFile != "" { + return chezmoi.AbsPath(c.configFile).Dir().Join(persistentStateFilename) + } + for _, configDir := range c.bds.ConfigDirs { + configDirAbsPath := chezmoi.AbsPath(configDir) + persistentStateFile := configDirAbsPath.Join(chezmoi.RelPath("chezmoi"), persistentStateFilename) + if _, err := os.Stat(string(persistentStateFile)); err == nil { + return persistentStateFile + } + } + return defaultConfigFile(c.fs, c.bds).Dir().Join(persistentStateFilename) +} + +func (c *Config) prompt(s, choices string) (byte, error) { + ttyReader, ttyWriter, err := c.getTTY() + if err != nil { + return 0, err + } + for { + _, err := fmt.Fprintf(ttyWriter, "%s [%s]? ", s, strings.Join(strings.Split(choices, ""), ",")) + if err != nil { + return 0, err + } + line, err := ttyReader.ReadString('\n') + if err != nil { + return 0, err + } + line = strings.TrimSpace(line) + if len(line) == 1 && strings.IndexByte(choices, line[0]) != -1 { + return line[0], nil + } + } +} + +func (c *Config) readConfig() error { + v := viper.New() + v.SetConfigFile(string(c.configFileAbsPath)) + v.SetFs(vfsafero.NewAferoFS(c.fs)) + switch err := v.ReadInConfig(); { + case os.IsNotExist(err): + return nil + case err != nil: + return err + } + // FIXME calling v.Unmarshal here overwrites values set with command line flags + if err := v.Unmarshal(c); err != nil { + return err + } + if err := c.validateData(); err != nil { + return err + } + return nil +} + +func (c *Config) run(dir chezmoi.AbsPath, name string, args []string) error { + cmd := exec.Command(name, args...) + if dir != "" { + dirRawAbsPath, err := c.baseSystem.RawPath(dir) + if err != nil { + return err + } + cmd.Dir = string(dirRawAbsPath) + } + cmd.Stdin = c.stdin + cmd.Stdout = c.stdout + cmd.Stderr = c.stderr + return c.baseSystem.RunCmd(cmd) +} + +func (c *Config) runEditor(args []string) error { + editor, editorArgs := c.editor() + return c.run("", editor, append(editorArgs, args...)) +} + +func (c *Config) sourceAbsPaths(sourceState *chezmoi.SourceState, args []string) (chezmoi.AbsPaths, error) { + targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ + mustBeInSourceState: true, + }) + if err != nil { + return nil, err + } + sourceAbsPaths := make(chezmoi.AbsPaths, 0, len(targetRelPaths)) + for _, targetRelPath := range targetRelPaths { + sourceAbsPath := c.sourceDirAbsPath.Join(sourceState.MustEntry(targetRelPath).SourceRelPath().RelPath()) + sourceAbsPaths = append(sourceAbsPaths, sourceAbsPath) + } + return sourceAbsPaths, nil +} + +func (c *Config) sourceState() (*chezmoi.SourceState, error) { + s := chezmoi.NewSourceState( + chezmoi.WithDefaultTemplateDataFunc(c.defaultTemplateData), + chezmoi.WithDestDir(c.destDirAbsPath), + chezmoi.WithEncryption(c.encryption), + chezmoi.WithPriorityTemplateData(c.Data), + chezmoi.WithSourceDir(c.sourceDirAbsPath), + chezmoi.WithSystem(c.sourceSystem), + chezmoi.WithTemplateFuncs(c.templateFuncs), + chezmoi.WithTemplateOptions(c.Template.Options), + ) + + if err := s.Read(); err != nil { + return nil, err + } + + if minVersion := s.MinVersion(); c.version != nil && c.version.LessThan(minVersion) { + return nil, fmt.Errorf("source state requires version %s or later, chezmoi is version %s", minVersion, c.version) + } + + return s, nil +} + +type targetRelPathsOptions struct { + mustBeInSourceState bool + recursive bool +} + +func (c *Config) targetRelPaths(sourceState *chezmoi.SourceState, args []string, options targetRelPathsOptions) (chezmoi.RelPaths, error) { + targetRelPaths := make(chezmoi.RelPaths, 0, len(args)) + for _, arg := range args { + argAbsPath, err := chezmoi.NewAbsPathFromExtPath(arg, c.homeDirAbsPath) + if err != nil { + return nil, err + } + targetRelPath, err := argAbsPath.TrimDirPrefix(c.destDirAbsPath) + if err != nil { + return nil, err + } + if err != nil { + return nil, err + } + if options.mustBeInSourceState { + if _, ok := sourceState.Entry(targetRelPath); !ok { + return nil, fmt.Errorf("%s: not in source state", arg) + } + } + targetRelPaths = append(targetRelPaths, targetRelPath) + if options.recursive { + parentRelPath := targetRelPath + // FIXME we should not call s.TargetRelPaths() here - risk of accidentally quadratic + for _, targetRelPath := range sourceState.TargetRelPaths() { + if _, err := targetRelPath.TrimDirPrefix(parentRelPath); err == nil { + targetRelPaths = append(targetRelPaths, targetRelPath) + } + } + } + } + + if len(targetRelPaths) == 0 { + return nil, nil + } + + // Sort and de-duplicate targetRelPaths in place. + sort.Sort(targetRelPaths) + n := 1 + for i := 1; i < len(targetRelPaths); i++ { + if targetRelPaths[i] != targetRelPaths[i-1] { + targetRelPaths[n] = targetRelPaths[i] + n++ + } + } + return targetRelPaths[:n], nil +} + +func (c *Config) useBuiltinGit() (bool, error) { + if c.UseBuiltinGit == "" || strings.ToLower(c.UseBuiltinGit) == "auto" { + if _, err := exec.LookPath(c.Git.Command); err == nil { + return false, nil + } + return true, nil + } + return parseBool(c.UseBuiltinGit) +} + +func (c *Config) validateData() error { + return validateKeys(c.Data, identifierRx) +} + +func (c *Config) writeOutput(data []byte) error { + if c.outputStr == "" || c.outputStr == "-" { + _, err := c.stdout.Write(data) + return err + } + return c.baseSystem.WriteFile(chezmoi.AbsPath(c.outputStr), data, 0o666) +} + +func (c *Config) writeOutputString(data string) error { + return c.writeOutput([]byte(data)) +} + +// withVersionInfo sets the version information. +func withVersionInfo(versionInfo VersionInfo) configOption { + return func(c *Config) error { + var version *semver.Version + var versionElems []string + if versionInfo.Version != "" { + var err error + version, err = semver.NewVersion(strings.TrimPrefix(versionInfo.Version, "v")) + if err != nil { + return err + } + versionElems = append(versionElems, version.String()) + } else { + versionElems = append(versionElems, "dev") + } + if versionInfo.Commit != "" { + versionElems = append(versionElems, "commit "+versionInfo.Commit) + } + if versionInfo.Date != "" { + versionElems = append(versionElems, "built at "+versionInfo.Date) + } + if versionInfo.BuiltBy != "" { + versionElems = append(versionElems, "built by "+versionInfo.BuiltBy) + } + c.version = version + c.versionInfo = versionInfo + c.versionStr = strings.Join(versionElems, ", ") + return nil + } +} diff --git a/chezmoi2/cmd/config_test.go b/chezmoi2/cmd/config_test.go new file mode 100644 index 000000000000..db02e5a98a5d --- /dev/null +++ b/chezmoi2/cmd/config_test.go @@ -0,0 +1,250 @@ +package cmd + +import ( + "io" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + xdg "github.com/twpayne/go-xdg/v3" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestAddTemplateFuncPanic(t *testing.T) { + chezmoitest.WithTestFS(t, nil, func(fs vfs.FS) { + c := newTestConfig(t, fs) + assert.NotPanics(t, func() { + c.addTemplateFunc("func", nil) + }) + assert.Panics(t, func() { + c.addTemplateFunc("func", nil) + }) + }) +} + +func TestParseConfig(t *testing.T) { + for _, tc := range []struct { + name string + filename string + contents string + expectedColor bool + }{ + { + name: "json_bool", + filename: "chezmoi.json", + contents: chezmoitest.JoinLines( + `{`, + ` "color":true`, + `}`, + ), + expectedColor: true, + }, + { + name: "json_string", + filename: "chezmoi.json", + contents: chezmoitest.JoinLines( + `{`, + ` "color":"on"`, + `}`, + ), + expectedColor: true, + }, + { + name: "toml_bool", + filename: "chezmoi.toml", + contents: chezmoitest.JoinLines( + `color = true`, + ), + expectedColor: true, + }, + { + name: "toml_string", + filename: "chezmoi.toml", + contents: chezmoitest.JoinLines( + `color = "y"`, + ), + expectedColor: true, + }, + { + name: "yaml_bool", + filename: "chezmoi.yaml", + contents: chezmoitest.JoinLines( + `color: true`, + ), + expectedColor: true, + }, + { + name: "yaml_string", + filename: "chezmoi.yaml", + contents: chezmoitest.JoinLines( + `color: "yes"`, + ), + expectedColor: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]interface{}{ + "/home/user/.config/chezmoi/" + tc.filename: tc.contents, + }, func(fs vfs.FS) { + c := newTestConfig(t, fs) + require.NoError(t, c.execute([]string{"init"})) + assert.Equal(t, tc.expectedColor, c.color) + }) + }) + } +} + +func TestUpperSnakeCaseToCamelCase(t *testing.T) { + for s, expected := range map[string]string{ + "BUG_REPORT_URL": "bugReportURL", + "ID": "id", + "ID_LIKE": "idLike", + "NAME": "name", + "VERSION_CODENAME": "versionCodename", + "VERSION_ID": "versionID", + } { + assert.Equal(t, expected, upperSnakeCaseToCamelCase(s)) + } +} + +func TestValidateKeys(t *testing.T) { + for _, tc := range []struct { + data interface{} + expectedErr bool + }{ + { + data: nil, + expectedErr: false, + }, + { + data: map[string]interface{}{ + "foo": "bar", + "a": 0, + "_x9": false, + "ThisVariableIsExported": nil, + "αβ": "", + }, + expectedErr: false, + }, + { + data: map[string]interface{}{ + "foo-foo": "bar", + }, + expectedErr: true, + }, + { + data: map[string]interface{}{ + "foo": map[string]interface{}{ + "bar-bar": "baz", + }, + }, + expectedErr: true, + }, + { + data: map[string]interface{}{ + "foo": []interface{}{ + map[string]interface{}{ + "bar-bar": "baz", + }, + }, + }, + expectedErr: true, + }, + } { + if tc.expectedErr { + assert.Error(t, validateKeys(tc.data, identifierRx)) + } else { + assert.NoError(t, validateKeys(tc.data, identifierRx)) + } + } +} + +func newTestConfig(t *testing.T, fs vfs.FS, options ...configOption) *Config { + t.Helper() + system := chezmoi.NewRealSystem(fs) + c, err := newConfig( + append([]configOption{ + withBaseSystem(system), + withDestSystem(system), + withSourceSystem(system), + withTestFS(fs), + withTestUser("user"), + }, options...)..., + ) + require.NoError(t, err) + return c +} + +func withBaseSystem(baseSystem chezmoi.System) configOption { + return func(c *Config) error { + c.baseSystem = baseSystem + return nil + } +} + +func withDestSystem(destSystem chezmoi.System) configOption { + return func(c *Config) error { + c.destSystem = destSystem + return nil + } +} + +func withSourceSystem(sourceSystem chezmoi.System) configOption { + return func(c *Config) error { + c.sourceSystem = sourceSystem + return nil + } +} + +func withStdin(stdin io.Reader) configOption { + return func(c *Config) error { + c.stdin = stdin + return nil + } +} + +func withStdout(stdout io.Writer) configOption { + return func(c *Config) error { + c.stdout = stdout + return nil + } +} + +func withTestFS(fs vfs.FS) configOption { + return func(c *Config) error { + c.fs = fs + return nil + } +} + +func withTestUser(username string) configOption { + return func(c *Config) error { + var homeDirStr string + switch runtime.GOOS { + case "windows": + homeDirStr = `c:\home\user` + default: + homeDirStr = "/home/user" + } + c.HomeDir = homeDirStr + c.SourceDir = filepath.Join(homeDirStr, ".local", "share", "chezmoi") + c.DestDir = homeDirStr + c.Umask = 0o22 + configHome := filepath.Join(homeDirStr, ".config") + dataHome := filepath.Join(homeDirStr, ".local", "share") + c.bds = &xdg.BaseDirectorySpecification{ + ConfigHome: configHome, + ConfigDirs: []string{configHome}, + DataHome: dataHome, + DataDirs: []string{dataHome}, + CacheHome: filepath.Join(homeDirStr, ".cache"), + RuntimeDir: filepath.Join(homeDirStr, ".run"), + } + return nil + } +} diff --git a/chezmoi2/cmd/datacmd.go b/chezmoi2/cmd/datacmd.go new file mode 100644 index 000000000000..6c3e8f0e7d3d --- /dev/null +++ b/chezmoi2/cmd/datacmd.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +func (c *Config) newDataCmd() *cobra.Command { + dataCmd := &cobra.Command{ + Use: "data", + Short: "Print the template data", + Long: mustLongHelp("data"), + Example: example("data"), + Args: cobra.NoArgs, + RunE: c.makeRunEWithSourceState(c.runDataCmd), + } + + return dataCmd +} + +func (c *Config) runDataCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + return c.marshal(sourceState.TemplateData()) +} diff --git a/chezmoi2/cmd/datacmd_test.go b/chezmoi2/cmd/datacmd_test.go new file mode 100644 index 000000000000..583400b91c80 --- /dev/null +++ b/chezmoi2/cmd/datacmd_test.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestDataCmd(t *testing.T) { + for _, tc := range []struct { + format string + root map[string]interface{} + }{ + { + format: "json", + root: map[string]interface{}{ + "/home/user/.config/chezmoi/chezmoi.json": chezmoitest.JoinLines( + `{`, + ` "sourceDir": "/tmp/source",`, + ` "data": {`, + ` "test": true`, + ` }`, + `}`, + ), + }, + }, + { + format: "toml", + root: map[string]interface{}{ + "/home/user/.config/chezmoi/chezmoi.toml": chezmoitest.JoinLines( + `sourceDir = "/tmp/source"`, + `[data]`, + ` test = true`, + ), + }, + }, + { + format: "yaml", + root: map[string]interface{}{ + "/home/user/.config/chezmoi/chezmoi.yaml": chezmoitest.JoinLines( + `sourceDir: /tmp/source`, + `data:`, + ` test: true`, + ), + }, + }, + } { + t.Run(tc.format, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fs vfs.FS) { + args := []string{ + "data", + "--format", tc.format, + } + c := newTestConfig(t, fs) + var sb strings.Builder + c.stdout = &sb + require.NoError(t, c.execute(args)) + + var data struct { + Chezmoi struct { + SourceDir string `json:"sourceDir" toml:"sourceDir" yaml:"sourceDir"` + } `json:"chezmoi" toml:"chezmoi" yaml:"chezmoi"` + Test bool `json:"test" toml:"test" yaml:"test"` + } + assert.NoError(t, chezmoi.Formats[tc.format].Unmarshal([]byte(sb.String()), &data)) + normalizedSourceDir, err := chezmoi.NormalizePath("/tmp/source") + require.NoError(t, err) + assert.Equal(t, string(normalizedSourceDir), data.Chezmoi.SourceDir) + assert.True(t, data.Test) + }) + }) + } +} diff --git a/chezmoi2/cmd/diffcmd.go b/chezmoi2/cmd/diffcmd.go new file mode 100644 index 000000000000..f1a3099c3dca --- /dev/null +++ b/chezmoi2/cmd/diffcmd.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type diffCmdConfig struct { + include *chezmoi.IncludeSet + recursive bool + NoPager bool + Pager string +} + +func (c *Config) newDiffCmd() *cobra.Command { + diffCmd := &cobra.Command{ + Use: "diff [target]...", + Short: "Print the diff between the target state and the destination state", + Long: mustLongHelp("diff"), + Example: example("diff"), + RunE: c.runDiffCmd, + Annotations: map[string]string{ + persistentStateMode: persistentStateModeReadMockWrite, + }, + } + + flags := diffCmd.Flags() + flags.VarP(c.Diff.include, "include", "i", "include entry types") + flags.BoolVar(&c.Diff.NoPager, "no-pager", c.Diff.NoPager, "disable pager") + flags.BoolVarP(&c.Diff.recursive, "recursive", "r", c.Diff.recursive, "recursive") + + return diffCmd +} + +func (c *Config) runDiffCmd(cmd *cobra.Command, args []string) error { + sb := strings.Builder{} + dryRunSystem := chezmoi.NewDryRunSystem(c.destSystem) + gitDiffSystem := chezmoi.NewGitDiffSystem(dryRunSystem, &sb, c.destDirAbsPath, c.color) + if err := c.applyArgs(gitDiffSystem, c.destDirAbsPath, args, applyArgsOptions{ + include: c.Diff.include, + recursive: c.Diff.recursive, + umask: c.Umask.FileMode(), + }); err != nil { + return err + } + return c.writeOutputString(sb.String()) +} diff --git a/chezmoi2/cmd/docs.gen.go b/chezmoi2/cmd/docs.gen.go new file mode 100644 index 000000000000..f212ff7b48d4 --- /dev/null +++ b/chezmoi2/cmd/docs.gen.go @@ -0,0 +1,3243 @@ +// Code generated by github.com/twpayne/chezmoi/internal/cmd/generate-assets. DO NOT EDIT. +// +build !noembeddocs + +package cmd + +func init() { + assets["docs/CHANGES.md"] = []byte("" + + "# chezmoi Changes\n" + + "\n" + + "\n" + + "* [Upcoming](#upcoming)\n" + + " * [Default diff format changing from `chezmoi` to `git`.](#default-diff-format-changing-from-chezmoi-to-git)\n" + + " * [`gpgRecipient` config variable changing to `gpg.recipient`](#gpgrecipient-config-variable-changing-to-gpgrecipient)\n" + + "\n" + + "## Upcoming\n" + + "\n" + + "### Default diff format changing from `chezmoi` to `git`.\n" + + "\n" + + "Currently chezmoi outputs diffs in its own format, containing a mix of unified\n" + + "diffs and shell commands. This will be replaced with a [git format\n" + + "diff](https://git-scm.com/docs/diff-format) in version 2.0.0.\n" + + "\n" + + "### `gpgRecipient` config variable changing to `gpg.recipient`\n" + + "\n" + + "The `gpgRecipient` config variable is changing to `gpg.recipient`. To update,\n" + + "change your config from:\n" + + "\n" + + " gpgRecipient = \"...\"\n" + + "\n" + + "to:\n" + + "\n" + + " [gpg]\n" + + " recipient = \"...\"\n" + + "\n" + + "Support for the `gpgRecipient` config variable will be removed in version 2.0.0.\n" + + "\n") + assets["docs/CONTRIBUTING.md"] = []byte("" + + "# chezmoi Contributing Guide\n" + + "\n" + + "\n" + + "* [Getting started](#getting-started)\n" + + "* [Developing locally](#developing-locally)\n" + + "* [Generated code](#generated-code)\n" + + "* [Contributing changes](#contributing-changes)\n" + + "* [Managing releases](#managing-releases)\n" + + "* [Packaging](#packaging)\n" + + "* [Updating the website](#updating-the-website)\n" + + "\n" + + "## Getting started\n" + + "\n" + + "chezmoi is written in [Go](https://golang.org) and development happens on\n" + + "[GitHub](https://github.com). The rest of this document assumes that you've\n" + + "checked out chezmoi locally.\n" + + "\n" + + "## Developing locally\n" + + "\n" + + "chezmoi requires Go 1.14 or later and Go modules enabled. Enable Go modules by\n" + + "setting the environment variable `GO111MODULE=on`.\n" + + "\n" + + "chezmoi is a standard Go project, using standard Go tooling, with a few extra\n" + + "tools. Ensure that these extra tools are installed with:\n" + + "\n" + + " make ensure-tools\n" + + "\n" + + "Build chezmoi:\n" + + "\n" + + " go build .\n" + + "\n" + + "Run all tests:\n" + + "\n" + + " go test ./...\n" + + "\n" + + "Run chezmoi:\n" + + "\n" + + " go run .\n" + + "\n" + + "## Generated code\n" + + "\n" + + "chezmoi generates help text, shell completions, embedded files, and the website\n" + + "from a single source of truth. You must run\n" + + "\n" + + " go generate\n" + + "\n" + + "if you change includes any of the following:\n" + + "\n" + + "* Modify any documentation in the `docs/` directory.\n" + + "* Modify any files in the `assets/templates/` directory.\n" + + "* Add or modify a command.\n" + + "* Add or modify a command's flags.\n" + + "\n" + + "chezmoi's continuous integration verifies that all generated files are up to\n" + + "date. Changes to generated files should be included in the commit that modifies\n" + + "the source of truth.\n" + + "\n" + + "## Contributing changes\n" + + "\n" + + "Bug reports, bug fixes, and documentation improvements are always welcome.\n" + + "Please [open an issue](https://github.com/twpayne/chezmoi/issues/new/choose) or\n" + + "[create a pull\n" + + "request](https://help.github.com/en/articles/creating-a-pull-request) with your\n" + + "report, fix, or improvement.\n" + + "\n" + + "If you want to make a more significant change, please first [open an\n" + + "issue](https://github.com/twpayne/chezmoi/issues/new/choose) to discuss the\n" + + "change that you want to make. Dave Cheney gives a [good\n" + + "rationale](https://dave.cheney.net/2019/02/18/talk-then-code) as to why this is\n" + + "important.\n" + + "\n" + + "All changes are made via pull requests. In your pull request, please make sure\n" + + "that:\n" + + "\n" + + "* All existing tests pass.\n" + + "\n" + + "* There are appropriate additional tests that demonstrate that your PR works as\n" + + " intended.\n" + + "\n" + + "* The documentation is updated, if necessary. For new features you should add an\n" + + " entry in `docs/HOWTO.md` and a complete description in `docs/REFERENCE.md`.\n" + + "\n" + + "* All generated files are up to date. You can ensure this by running `go\n" + + " generate` and including any modified files in your commit.\n" + + "\n" + + "* The code is correctly formatted, according to\n" + + " [`gofumports`](https://mvdan.cc/gofumpt/gofumports). You can ensure this by\n" + + " running `make format`.\n" + + "\n" + + "* The code passes [`golangci-lint`](https://github.com/golangci/golangci-lint).\n" + + " You can ensure this by running `make lint`.\n" + + "\n" + + "* The commit messages match chezmoi's convention, specifically that they begin\n" + + " with a capitalized verb in the imperative and give a short description of what\n" + + " the commit does. Detailed information or justification can be optionally\n" + + " included in the body of the commit message.\n" + + "\n" + + "* Commits are logically separate, with no merge or \"fixup\" commits.\n" + + "\n" + + "* The branch applies cleanly to `master`.\n" + + "\n" + + "## Managing releases\n" + + "\n" + + "Releases are managed with [`goreleaser`](https://goreleaser.com/).\n" + + "\n" + + "To build a test release, without publishing, (Linux only) run:\n" + + "\n" + + " make test-release\n" + + "\n" + + "Publish a new release by creating and pushing a tag, e.g.:\n" + + "\n" + + " git tag v1.2.3\n" + + " git push --tags\n" + + "\n" + + "This triggers a [GitHub Action](https://github.com/twpayne/chezmoi/actions) that\n" + + "builds and publishes archives, packages, and snaps, and creates a new [GitHub\n" + + "Release](https://github.com/twpayne/chezmoi/releases).\n" + + "\n" + + "Publishing [Snaps](https://snapcraft.io/) requires a `SNAPCRAFT_LOGIN`\n" + + "[repository\n" + + "secret](https://github.com/twpayne/chezmoi/settings/secrets/actions). Snapcraft\n" + + "logins periodically expire. Create a new snapcraft login by running:\n" + + "\n" + + " snapcraft export-login --snaps=chezmoi --channels=stable --acls=package_upload -\n" + + "\n" + + "[brew](https://brew.sh/) formula must be updated manually with the command:\n" + + "\n" + + " brew bump-formula-pr --tag=v1.2.3 chezmoi\n" + + "\n" + + "## Packaging\n" + + "\n" + + "If you're packaging chezmoi for an operating system or distribution:\n" + + "\n" + + "* chezmoi has no build or install dependencies other than the standard Go\n" + + " toolchain.\n" + + "\n" + + "* Please set the version number, git commit, and build time in the binary. This\n" + + " greatly assists debugging when end users report problems or ask for help. You\n" + + " can do this by passing the following flags to the Go linker:\n" + + "\n" + + " ```\n" + + " -X main.version=$VERSION\n" + + " -X main.commit=$COMMIT\n" + + " -X main.date=$DATE\n" + + " -X main.builtBy=$BUILT_BY\n" + + " ```\n" + + "\n" + + " `$VERSION` should be the chezmoi version, e.g. `1.7.3`. Any `v` prefix is\n" + + " optional and will be stripped, so you can pass the git tag in directly.\n" + + "\n" + + " `$COMMIT` should be the full git commit hash at which chezmoi is built, e.g.\n" + + " `4d678ce6850c9d81c7ab2fe0d8f20c1547688b91`.\n" + + "\n" + + " `$DATE` should be the date of the build in RFC3339 format, e.g.\n" + + " `2019-11-23T18:29:25Z`.\n" + + "\n" + + " `$BUILT_BY` should be a string indicating what mechanism was used to build the\n" + + " binary, e.g. `goreleaser`.\n" + + "\n" + + "* Please enable cgo, if possible. chezmoi can be built and run without cgo, but\n" + + " the `.chezmoi.username` and `.chezmoi.group` template variables may not be set\n" + + " correctly on some systems.\n" + + "\n" + + "* chezmoi includes a `docs` command which prints its documentation. By default,\n" + + " the docs are embedded in the binary. You can disable this behavior, and have\n" + + " chezmoi read its docs from the filesystem by building with the `noembeddocs`\n" + + " build tag and setting the directory where chezmoi can find them with the `-X\n" + + " github.com/twpayne/chezmoi/cmd.DocDir=$DOCDIR` linker flag. For example:\n" + + "\n" + + " ```\n" + + " go build -tags noembeddocs -ldflags \"-X github.com/twpayne/chezmoi/cmd.DocsDir=/usr/share/doc/chezmoi\" .\n" + + " ```\n" + + "\n" + + " To remove the `docs` command completely, use the `nodocs` build tag.\n" + + "\n" + + "* chezmoi includes an `upgrade` command which attempts to self-upgrade. You can\n" + + " remove this command completely by building chezmoi with the `noupgrade` build\n" + + " tag.\n" + + "\n" + + "* chezmoi includes shell completions in the `completions` directory. Please\n" + + " include these in the package and install them in the shell-appropriate\n" + + " directory, if possible.\n" + + "\n" + + "* If the instructions for installing chezmoi in chezmoi's [install\n" + + " guide](https://github.com/twpayne/chezmoi/blob/master/docs/INSTALL.md) are\n" + + " absent or incorrect, please open an issue or submit a PR to correct them.\n" + + "\n" + + "## Updating the website\n" + + "\n" + + "[The website](https://chezmoi.io) is generated with [Hugo](https://gohugo.io/)\n" + + "and served with [GitHub pages](https://pages.github.com/) from the [`gh-pages`\n" + + "branch](https://github.com/twpayne/chezmoi/tree/gh-pages) to GitHub.\n" + + "\n" + + "Before building the website, you must download the [Hugo Book\n" + + "Theme](https://github.com/alex-shpak/hugo-book) by running:\n" + + "\n" + + " git submodule update --init\n" + + "\n" + + "Test the website locally by running:\n" + + "\n" + + " ( cd chezmoi.io && hugo serve )\n" + + "\n" + + "and visit http://localhost:1313/.\n" + + "\n" + + "To build the website in a temporary directory, run:\n" + + "\n" + + " ( cd chezmoi.io && make )\n" + + "\n" + + "From here you can run\n" + + "\n" + + " git show\n" + + "\n" + + "to show changes and\n" + + "\n" + + " git push\n" + + "\n" + + "to push them. You can only push changes if you have write permissions to the\n" + + "chezmoi GitHub repo.\n" + + "\n") + assets["docs/FAQ.md"] = []byte("" + + "# chezmoi Frequently Asked Questions\n" + + "\n" + + "\n" + + "* [How can I quickly check for problems with chezmoi on my machine?](#how-can-i-quickly-check-for-problems-with-chezmoi-on-my-machine)\n" + + "* [What are the consequences of \"bare\" modifications to the target files? If my `.zshrc` is managed by chezmoi and I edit `~/.zshrc` without using `chezmoi edit`, what happens?](#what-are-the-consequences-of-bare-modifications-to-the-target-files-if-my-zshrc-is-managed-by-chezmoi-and-i-edit-zshrc-without-using-chezmoi-edit-what-happens)\n" + + "* [How can I tell what dotfiles in my home directory aren't managed by chezmoi? Is there an easy way to have chezmoi manage a subset of them?](#how-can-i-tell-what-dotfiles-in-my-home-directory-arent-managed-by-chezmoi-is-there-an-easy-way-to-have-chezmoi-manage-a-subset-of-them)\n" + + "* [How can I tell what dotfiles in my home directory are currently managed by chezmoi?](#how-can-i-tell-what-dotfiles-in-my-home-directory-are-currently-managed-by-chezmoi)\n" + + "* [If there's a mechanism in place for the above, is there also a way to tell chezmoi to ignore specific files or groups of files (e.g. by directory name or by glob)?](#if-theres-a-mechanism-in-place-for-the-above-is-there-also-a-way-to-tell-chezmoi-to-ignore-specific-files-or-groups-of-files-eg-by-directory-name-or-by-glob)\n" + + "* [If the target already exists, but is \"behind\" the source, can chezmoi be configured to preserve the target version before replacing it with one derived from the source?](#if-the-target-already-exists-but-is-behind-the-source-can-chezmoi-be-configured-to-preserve-the-target-version-before-replacing-it-with-one-derived-from-the-source)\n" + + "* [Once I've made a change to the source directory, how do I commit it?](#once-ive-made-a-change-to-the-source-directory-how-do-i-commit-it)\n" + + "* [How do I only run a script when a file has changed?](#how-do-i-only-run-a-script-when-a-file-has-changed)\n" + + "* [I've made changes to both the destination state and the source state that I want to keep. How can I keep them both?](#ive-made-changes-to-both-the-destination-state-and-the-source-state-that-i-want-to-keep-how-can-i-keep-them-both)\n" + + "* [Why does chezmoi convert all my template variables to lowercase?](#why-does-chezmoi-convert-all-my-template-variables-to-lowercase)\n" + + "* [chezmoi makes `~/.ssh/config` group writeable. How do I stop this?](#chezmoi-makes-sshconfig-group-writeable-how-do-i-stop-this)\n" + + "* [Can I change how chezmoi's source state is represented on disk?](#can-i-change-how-chezmois-source-state-is-represented-on-disk)\n" + + "* [gpg encryption fails. What could be wrong?](#gpg-encryption-fails-what-could-be-wrong)\n" + + "* [chezmoi reports \"user: lookup userid NNNNN: input/output error\"](#chezmoi-reports-user-lookup-userid-nnnnn-inputoutput-error)\n" + + "* [I'm getting errors trying to build chezmoi from source](#im-getting-errors-trying-to-build-chezmoi-from-source)\n" + + "* [What inspired chezmoi?](#what-inspired-chezmoi)\n" + + "* [Why not use Ansible/Chef/Puppet/Salt, or similar to manage my dotfiles instead?](#why-not-use-ansiblechefpuppetsalt-or-similar-to-manage-my-dotfiles-instead)\n" + + "* [Can I use chezmoi to manage files outside my home directory?](#can-i-use-chezmoi-to-manage-files-outside-my-home-directory)\n" + + "* [Where does the name \"chezmoi\" come from?](#where-does-the-name-chezmoi-come-from)\n" + + "* [What other questions have been asked about chezmoi?](#what-other-questions-have-been-asked-about-chezmoi)\n" + + "* [Where do I ask a question that isn't answered here?](#where-do-i-ask-a-question-that-isnt-answered-here)\n" + + "* [I like chezmoi. How do I say thanks?](#i-like-chezmoi-how-do-i-say-thanks)\n" + + "\n" + + "## How can I quickly check for problems with chezmoi on my machine?\n" + + "\n" + + "Run:\n" + + "\n" + + " chezmoi doctor\n" + + "\n" + + "Anything `ok` is fine, anything `warning` is only a problem if you want to use\n" + + "the related feature, and anything `error` indicates a definite problem.\n" + + "\n" + + "## What are the consequences of \"bare\" modifications to the target files? If my `.zshrc` is managed by chezmoi and I edit `~/.zshrc` without using `chezmoi edit`, what happens?\n" + + "\n" + + "chezmoi will overwrite the file the next time you run `chezmoi apply`. Until you\n" + + "run `chezmoi apply` your modified `~/.zshrc` will remain in place.\n" + + "\n" + + "## How can I tell what dotfiles in my home directory aren't managed by chezmoi? Is there an easy way to have chezmoi manage a subset of them?\n" + + "\n" + + "`chezmoi unmanaged` will list everything not managed by chezmoi. You can add\n" + + "entire directories with `chezmoi add -r`.\n" + + "\n" + + "## How can I tell what dotfiles in my home directory are currently managed by chezmoi?\n" + + "\n" + + "`chezmoi managed` will list everything managed by chezmoi.\n" + + "\n" + + "## If there's a mechanism in place for the above, is there also a way to tell chezmoi to ignore specific files or groups of files (e.g. by directory name or by glob)?\n" + + "\n" + + "By default, chezmoi ignores everything that you haven't explicitly `chezmoi\n" + + "add`'ed. If you have files in your source directory that you don't want added to\n" + + "your destination directory when you run `chezmoi apply` add their names to a\n" + + "file called `.chezmoiignore` in the source state.\n" + + "\n" + + "Patterns are supported, and you can change what's ignored from machine to\n" + + "machine. The full usage and syntax is described in the [reference\n" + + "manual](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#chezmoiignore).\n" + + "\n" + + "## If the target already exists, but is \"behind\" the source, can chezmoi be configured to preserve the target version before replacing it with one derived from the source?\n" + + "\n" + + "Yes. Run `chezmoi add` will update the source state with the target. To see\n" + + "diffs of what would change, without actually changing anything, use `chezmoi\n" + + "diff`.\n" + + "\n" + + "## Once I've made a change to the source directory, how do I commit it?\n" + + "\n" + + "You have several options:\n" + + "\n" + + "* `chezmoi cd` opens a shell in the source directory, where you can run your\n" + + " usual version control commands, like `git add` and `git commit`.\n" + + "* `chezmoi git` and `chezmoi hg` run `git` and `hg` respectively in the source\n" + + " directory and pass extra arguments to the command. If you're passing any\n" + + " flags, you'll need to use `--` to prevent chezmoi from consuming them, for\n" + + " example `chezmoi git -- commit -m \"Update dotfiles\"`.\n" + + "* `chezmoi source` runs your configured version control system in your source\n" + + " directory. It works in the same way as the `chezmoi git` and `chezmoi hg`\n" + + " commands, but uses `sourceVCS.command`.\n" + + "\n" + + "## How do I only run a script when a file has changed?\n" + + "\n" + + "A common example of this is that you're using [Homebrew](https://brew.sh/) and\n" + + "have `.Brewfile` listing all the packages that you want installed and only want\n" + + "to run `brew bundle --global` when the contents of `.Brewfile` have changed.\n" + + "\n" + + "chezmoi has two types of scripts: scripts that run every time, and scripts that\n" + + "only run when their contents change. chezmoi does not have a mechanism to run a\n" + + "script when an arbitrary file has changed, but there are some ways to achieve\n" + + "the desired behavior:\n" + + "\n" + + "1. Have the script create `.Brewfile` instead of chezmoi, e.g. in your\n" + + " `run_once_install-packages`:\n" + + "\n" + + " ```sh\n" + + " #!/bin/sh\n" + + "\n" + + " cat > $HOME/.Brewfile <\n" + + "* [Use a hosted repo to manage your dotfiles across multiple machines](#use-a-hosted-repo-to-manage-your-dotfiles-across-multiple-machines)\n" + + "* [Pull the latest changes from your repo and apply them](#pull-the-latest-changes-from-your-repo-and-apply-them)\n" + + "* [Pull the latest changes from your repo and see what would change, without actually applying the changes](#pull-the-latest-changes-from-your-repo-and-see-what-would-change-without-actually-applying-the-changes)\n" + + "* [Automatically commit and push changes to your repo](#automatically-commit-and-push-changes-to-your-repo)\n" + + "* [Use templates to manage files that vary from machine to machine](#use-templates-to-manage-files-that-vary-from-machine-to-machine)\n" + + "* [Use completely separate config files on different machines](#use-completely-separate-config-files-on-different-machines)\n" + + " * [Without using symlinks](#without-using-symlinks)\n" + + "* [Create a config file on a new machine automatically](#create-a-config-file-on-a-new-machine-automatically)\n" + + "* [Have chezmoi create a directory, but ignore its contents](#have-chezmoi-create-a-directory-but-ignore-its-contents)\n" + + "* [Ensure that a target is removed](#ensure-that-a-target-is-removed)\n" + + "* [Include a subdirectory from another repository, like Oh My Zsh](#include-a-subdirectory-from-another-repository-like-oh-my-zsh)\n" + + "* [Handle configuration files which are externally modified](#handle-configuration-files-which-are-externally-modified)\n" + + "* [Handle different file locations on different systems with the same contents](#handle-different-file-locations-on-different-systems-with-the-same-contents)\n" + + "* [Keep data private](#keep-data-private)\n" + + " * [Use Bitwarden to keep your secrets](#use-bitwarden-to-keep-your-secrets)\n" + + " * [Use gopass to keep your secrets](#use-gopass-to-keep-your-secrets)\n" + + " * [Use gpg to keep your secrets](#use-gpg-to-keep-your-secrets)\n" + + " * [Use KeePassXC to keep your secrets](#use-keepassxc-to-keep-your-secrets)\n" + + " * [Use a keyring to keep your secrets](#use-a-keyring-to-keep-your-secrets)\n" + + " * [Use LastPass to keep your secrets](#use-lastpass-to-keep-your-secrets)\n" + + " * [Use 1Password to keep your secrets](#use-1password-to-keep-your-secrets)\n" + + " * [Use pass to keep your secrets](#use-pass-to-keep-your-secrets)\n" + + " * [Use Vault to keep your secrets](#use-vault-to-keep-your-secrets)\n" + + " * [Use a generic tool to keep your secrets](#use-a-generic-tool-to-keep-your-secrets)\n" + + " * [Use templates variables to keep your secrets](#use-templates-variables-to-keep-your-secrets)\n" + + "* [Use scripts to perform actions](#use-scripts-to-perform-actions)\n" + + " * [Understand how scripts work](#understand-how-scripts-work)\n" + + " * [Install packages with scripts](#install-packages-with-scripts)\n" + + "* [Use chezmoi with GitHub Codespaces, Visual Studio Codespaces, Visual Studio Code Remote - Containers](#use-chezmoi-with-github-codespaces-visual-studio-codespaces-visual-studio-code-remote---containers)\n" + + "* [Detect Windows Subsystem for Linux (WSL)](#detect-windows-subsystem-for-linux-wsl)\n" + + "* [Run a PowerShell script as admin on Windows](#run-a-powershell-script-as-admin-on-windows)\n" + + "* [Import archives](#import-archives)\n" + + "* [Export archives](#export-archives)\n" + + "* [Use a non-git version control system](#use-a-non-git-version-control-system)\n" + + "* [Customize the `diff` command](#customize-the-diff-command)\n" + + "* [Use a merge tool other than vimdiff](#use-a-merge-tool-other-than-vimdiff)\n" + + "* [Migrate from a dotfile manager that uses symlinks](#migrate-from-a-dotfile-manager-that-uses-symlinks)\n" + + "\n" + + "## Use a hosted repo to manage your dotfiles across multiple machines\n" + + "\n" + + "chezmoi relies on your version control system and hosted repo to share changes\n" + + "across multiple machines. You should create a repo on the source code repository\n" + + "of your choice (e.g. [Bitbucket](https://bitbucket.org),\n" + + "[GitHub](https://github.com/), or [GitLab](https://gitlab.com), many people call\n" + + "their repo `dotfiles`) and push the repo in the source directory here. For\n" + + "example:\n" + + "\n" + + " chezmoi cd\n" + + " git remote add origin https://github.com/username/dotfiles.git\n" + + " git push -u origin master\n" + + " exit\n" + + "\n" + + "On another machine you can checkout this repo:\n" + + "\n" + + " chezmoi init https://github.com/username/dotfiles.git\n" + + "\n" + + "You can then see what would be changed:\n" + + "\n" + + " chezmoi diff\n" + + "\n" + + "If you're happy with the changes then apply them:\n" + + "\n" + + " chezmoi apply\n" + + "\n" + + "The above commands can be combined into a single init, checkout, and apply:\n" + + "\n" + + " chezmoi init --apply --verbose https://github.com/username/dotfiles.git\n" + + "\n" + + "## Pull the latest changes from your repo and apply them\n" + + "\n" + + "You can pull the changes from your repo and apply them in a single command:\n" + + "\n" + + " chezmoi update\n" + + "\n" + + "This runs `git pull --rebase` in your source directory and then `chezmoi apply`.\n" + + "\n" + + "## Pull the latest changes from your repo and see what would change, without actually applying the changes\n" + + "\n" + + "Run:\n" + + "\n" + + " chezmoi source pull -- --rebase && chezmoi diff\n" + + "\n" + + "This runs `git pull --rebase` in your source directory and `chezmoi\n" + + "diff` then shows the difference between the target state computed from your\n" + + "source directory and the actual state.\n" + + "\n" + + "If you're happy with the changes, then you can run\n" + + "\n" + + " chezmoi apply\n" + + "\n" + + "to apply them.\n" + + "\n" + + "## Automatically commit and push changes to your repo\n" + + "\n" + + "chezmoi can automatically commit and push changes to your source directory to\n" + + "your repo. This feature is disabled by default. To enable it, add the following\n" + + "to your config file:\n" + + "\n" + + " [sourceVCS]\n" + + " autoCommit = true\n" + + " autoPush = true\n" + + "\n" + + "Whenever a change is made to your source directory, chezmoi will commit the\n" + + "changes with an automatically-generated commit message (if `autoCommit` is true)\n" + + "and push them to your repo (if `autoPush` is true). `autoPush` implies\n" + + "`autoCommit`, i.e. if `autoPush` is true then chezmoi will auto-commit your\n" + + "changes. If you only set `autoCommit` to true then changes will be committed but\n" + + "not pushed.\n" + + "\n" + + "Be careful when using `autoPush`. If your dotfiles repo is public and you\n" + + "accidentally add a secret in plain text, that secret will be pushed to your\n" + + "public repo.\n" + + "\n" + + "## Use templates to manage files that vary from machine to machine\n" + + "\n" + + "The primary goal of chezmoi is to manage configuration files across multiple\n" + + "machines, for example your personal macOS laptop, your work Ubuntu desktop, and\n" + + "your work Linux laptop. You will want to keep much configuration the same across\n" + + "these, but also need machine-specific configurations for email addresses,\n" + + "credentials, etc. chezmoi achieves this functionality by using\n" + + "[`text/template`](https://pkg.go.dev/text/template) for the source state where\n" + + "needed.\n" + + "\n" + + "For example, your home `~/.gitconfig` on your personal machine might look like:\n" + + "\n" + + " [user]\n" + + " email = \"john@home.org\"\n" + + "\n" + + "Whereas at work it might be:\n" + + "\n" + + " [user]\n" + + " email = \"john.smith@company.com\"\n" + + "\n" + + "To handle this, on each machine create a configuration file called\n" + + "`~/.config/chezmoi/chezmoi.toml` defining variables that might vary from machine\n" + + "to machine. For example, for your home machine:\n" + + "\n" + + " [data]\n" + + " email = \"john@home.org\"\n" + + "\n" + + "Note that all variable names will be converted to lowercase. This is due to a\n" + + "feature of a library used by chezmoi.\n" + + "\n" + + "If you intend to store private data (e.g. access tokens) in\n" + + "`~/.config/chezmoi/chezmoi.toml`, make sure it has permissions `0600`.\n" + + "\n" + + "If you prefer, you can use any format supported by\n" + + "[Viper](https://github.com/spf13/viper) for your configuration file. This\n" + + "includes JSON, YAML, and TOML. Variable names must start with a letter and be\n" + + "followed by zero or more letters or digits.\n" + + "\n" + + "Then, add `~/.gitconfig` to chezmoi using the `--autotemplate` flag to turn it\n" + + "into a template and automatically detect variables from the `data` section\n" + + "of your `~/.config/chezmoi/chezmoi.toml` file:\n" + + "\n" + + " chezmoi add --autotemplate ~/.gitconfig\n" + + "\n" + + "You can then open the template (which will be saved in the file\n" + + "`~/.local/share/chezmoi/dot_gitconfig.tmpl`):\n" + + "\n" + + " chezmoi edit ~/.gitconfig\n" + + "\n" + + "The file should look something like:\n" + + "\n" + + " [user]\n" + + " email = \"{{ .email }}\"\n" + + "\n" + + "To disable automatic variable detection, use the `--template` or `-T` option to\n" + + "`chezmoi add` instead of `--autotemplate`.\n" + + "\n" + + "Templates are often used to capture machine-specific differences. For example,\n" + + "in your `~/.local/share/chezmoi/dot_bashrc.tmpl` you might have:\n" + + "\n" + + " # common config\n" + + " export EDITOR=vi\n" + + "\n" + + " # machine-specific configuration\n" + + " {{- if eq .chezmoi.hostname \"work-laptop\" }}\n" + + " # this will only be included in ~/.bashrc on work-laptop\n" + + " {{- end }}\n" + + "\n" + + "For a full list of variables, run:\n" + + "\n" + + " chezmoi data\n" + + "\n" + + "For more advanced usage, you can use the full power of the\n" + + "[`text/template`](https://pkg.go.dev/text/template) language. chezmoi includes\n" + + "all of the text functions from [sprig](http://masterminds.github.io/sprig/) and\n" + + "its own [functions for interacting with password\n" + + "managers](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#template-functions).\n" + + "\n" + + "Templates can be executed directly from the command line, without the need to\n" + + "create a file on disk, with the `execute-template` command, for example:\n" + + "\n" + + " chezmoi execute-template '{{ .chezmoi.os }}/{{ .chezmoi.arch }}'\n" + + "\n" + + "This is useful when developing or debugging templates.\n" + + "\n" + + "Some password managers allow you to store complete files. The files can be\n" + + "retrieved with chezmoi's template functions. For example, if you have a file\n" + + "stored in 1Password with the UUID `uuid` then you can retrieve it with the\n" + + "template:\n" + + "\n" + + " {{- onepasswordDocument \"uuid\" -}}\n" + + "\n" + + "The `-`s inside the brackets remove any whitespace before or after the template\n" + + "expression, which is useful if your editor has added any newlines.\n" + + "\n" + + "If, after executing the template, the file contents are empty, the target file\n" + + "will be removed. This can be used to ensure that files are only present on\n" + + "certain machines. If you want an empty file to be created anyway, you will need\n" + + "to give it an `empty_` prefix.\n" + + "\n" + + "For coarser-grained control of files and entire directories managed on different\n" + + "machines, or to exclude certain files completely, you can create\n" + + "`.chezmoiignore` files in the source directory. These specify a list of patterns\n" + + "that chezmoi should ignore, and are interpreted as templates. An example\n" + + "`.chezmoiignore` file might look like:\n" + + "\n" + + " README.md\n" + + " {{- if ne .chezmoi.hostname \"work-laptop\" }}\n" + + " .work # only manage .work on work-laptop\n" + + " {{- end }}\n" + + "\n" + + "The use of `ne` (not equal) is deliberate. What we want to achieve is \"only\n" + + "install `.work` if hostname is `work-laptop`\" but chezmoi installs everything by\n" + + "default, so we have to turn the logic around and instead write \"ignore `.work`\n" + + "unless the hostname is `work-laptop`\".\n" + + "\n" + + "Patterns can be excluded by prefixing them with a `!`, for example:\n" + + "\n" + + " f*\n" + + " !foo\n" + + "\n" + + "will ignore all files beginning with an `f` except `foo`.\n" + + "\n" + + "## Use completely separate config files on different machines\n" + + "\n" + + "chezmoi's template functionality allows you to change a file's contents based on\n" + + "any variable. For example, if you want `~/.bashrc` to be different on Linux and\n" + + "macOS you would create a file in the source state called `dot_bashrc.tmpl`\n" + + "containing:\n" + + "\n" + + "```\n" + + "{{ if eq .chezmoi.os \"darwin\" -}}\n" + + "# macOS .bashrc contents\n" + + "{{ else if eq .chezmoi.os \"linux\" -}}\n" + + "# Linux .bashrc contents\n" + + "{{ end -}}\n" + + "```\n" + + "\n" + + "However, if the differences between the two versions are so large that you'd\n" + + "prefer to use completely separate files in the source state, you can achieve\n" + + "this using a symbolic link template. Create the following files:\n" + + "\n" + + "`symlink_dot_bashrc.tmpl`:\n" + + "\n" + + "```\n" + + ".bashrc_{{ .chezmoi.os }}\n" + + "```\n" + + "\n" + + "`dot_bashrc_darwin`:\n" + + "\n" + + "```\n" + + "# macOS .bashrc contents\n" + + "```\n" + + "\n" + + "`dot_bashrc_linux`:\n" + + "\n" + + "```\n" + + "# Linux .bashrc contents\n" + + "```\n" + + "\n" + + "`.chezmoiignore`\n" + + "\n" + + "```\n" + + "{{ if ne .chezmoi.os \"darwin\" }}\n" + + ".bashrc_darwin\n" + + "{{ end }}\n" + + "{{ if ne .chezmoi.os \"linux\" }}\n" + + ".bashrc_linux\n" + + "{{ end }}\n" + + "```\n" + + "\n" + + "This will make `~/.bashrc` a symlink to `.bashrc_darwin` on `darwin` and to\n" + + "`.bashrc_linux` on `linux`. The `.chezmoiignore` configuration ensures that only\n" + + "the OS-specific `.bashrc_os` file will be installed on each OS.\n" + + "\n" + + "### Without using symlinks\n" + + "\n" + + "The same thing can be achieved using the include function.\n" + + "\n" + + "`dot_bashrc.tmpl`\n" + + "\n" + + "\t{{ if eq .chezmoi.os \"darwin\" }}\n" + + "\t{{ include \".bashrc_darwin\" }}\n" + + "\t{{ end }}\n" + + "\t{{ if eq .chezmoi.os \"linux\" }}\n" + + "\t{{ include \".bashrc_linux\" }}\n" + + "\t{{ end }}\n" + + "\n" + + "\n" + + "## Create a config file on a new machine automatically\n" + + "\n" + + "`chezmoi init` can also create a config file automatically, if one does not\n" + + "already exist. If your repo contains a file called `.chezmoi..tmpl`\n" + + "where *format* is one of the supported config file formats (e.g. `json`, `toml`,\n" + + "or `yaml`) then `chezmoi init` will execute that template to generate your\n" + + "initial config file.\n" + + "\n" + + "Specifically, if you have `.chezmoi.toml.tmpl` that looks like this:\n" + + "\n" + + " {{- $email := promptString \"email\" -}}\n" + + " [data]\n" + + " email = \"{{ $email }}\"\n" + + "\n" + + "Then `chezmoi init` will create an initial `chezmoi.toml` using this template.\n" + + "`promptString` is a special function that prompts the user (you) for a value.\n" + + "\n" + + "To test this template, use `chezmoi execute-template` with the `--init` and\n" + + "`--promptString` flags, for example:\n" + + "\n" + + " chezmoi execute-template --init --promptString email=john@home.org < ~/.local/share/chezmoi/.chezmoi.toml.tmpl\n" + + "\n" + + "## Have chezmoi create a directory, but ignore its contents\n" + + "\n" + + "If you want chezmoi to create a directory, but ignore its contents, say `~/src`,\n" + + "first run:\n" + + "\n" + + " mkdir -p $(chezmoi source-path)/src\n" + + "\n" + + "This creates the directory in the source state, which means that chezmoi will\n" + + "create it (if it does not already exist) when you run `chezmoi apply`.\n" + + "\n" + + "However, as this is an empty directory it will be ignored by git. So, create a\n" + + "file in the directory in the source state that will be seen by git (so git does\n" + + "not ignore the directory) but ignored by chezmoi (so chezmoi does not include it\n" + + "in the target state):\n" + + "\n" + + " touch $(chezmoi source-path)/src/.keep\n" + + "\n" + + "chezmoi automatically creates `.keep` files when you add an empty directory with\n" + + "`chezmoi add`.\n" + + "\n" + + "## Ensure that a target is removed\n" + + "\n" + + "Create a file called `.chezmoiremove` in the source directory containing a list\n" + + "of patterns of files to remove. When you run\n" + + "\n" + + " chezmoi apply --remove\n" + + "\n" + + "chezmoi will remove anything in the target directory that matches the pattern.\n" + + "As this command is potentially dangerous, you should run chezmoi in verbose,\n" + + "dry-run mode beforehand to see what would be removed:\n" + + "\n" + + " chezmoi apply --remove --dry-run --verbose\n" + + "\n" + + "`.chezmoiremove` is interpreted as a template, so you can remove different files\n" + + "on different machines. Negative matches (patterns prefixed with a `!`) or\n" + + "targets listed in `.chezmoiignore` will never be removed.\n" + + "\n" + + "## Include a subdirectory from another repository, like Oh My Zsh\n" + + "\n" + + "To include a subdirectory from another repository, e.g. [Oh My\n" + + "Zsh](https://github.com/robbyrussell/oh-my-zsh), you cannot use git submodules\n" + + "because chezmoi uses its own format for the source state and Oh My Zsh is not\n" + + "distributed in this format. Instead, you can use the `import` command to import\n" + + "a snapshot from a tarball:\n" + + "\n" + + " curl -s -L -o oh-my-zsh-master.tar.gz https://github.com/robbyrussell/oh-my-zsh/archive/master.tar.gz\n" + + " chezmoi import --strip-components 1 --destination ${HOME}/.oh-my-zsh oh-my-zsh-master.tar.gz\n" + + "\n" + + "Add `oh-my-zsh-master.tar.gz` to `.chezmoiignore` if you run these commands in\n" + + "your source directory so that chezmoi doesn't try to copy the tarball anywhere.\n" + + "\n" + + "Disable Oh My Zsh auto-updates by setting `DISABLE_AUTO_UPDATE=\"true\"` in\n" + + "`~/.zshrc`. Auto updates will cause the `~/.oh-my-zsh` directory to drift out of\n" + + "sync with chezmoi's source state. To update Oh My Zsh, re-run the `curl` and\n" + + "`chezmoi import` commands above.\n" + + "\n" + + "## Handle configuration files which are externally modified\n" + + "\n" + + "Some programs modify their configuration files. When you next run `chezmoi\n" + + "apply`, any modifications made by the program will be lost.\n" + + "\n" + + "You can track changes to these files by replacing with a symlink back to a file\n" + + "in your source directory, which is under version control. Here is a worked\n" + + "example for VSCode's `settings.json` on Linux:\n" + + "\n" + + "Copy the configuration file to your source directory:\n" + + "\n" + + " cp ~/.config/Code/User/settings.json $(chezmoi source-path)\n" + + "\n" + + "Tell chezmoi to ignore this file:\n" + + "\n" + + " echo settings.json >> $(chezmoi source-path)/.chezmoiignore\n" + + "\n" + + "Tell chezmoi that `~/.config/Code/User/settings.json` should be a symlink to the\n" + + "file in your source directory:\n" + + "\n" + + " mkdir -p $(chezmoi source-path)/private_dot_config/private_Code/User\n" + + " echo -n \"{{ .chezmoi.sourceDir }}/settings.json\" > $(chezmoi source-path)/private_dot_config/private_Code/User/symlink_settings.json.tmpl\n" + + "\n" + + "The prefix `private_` is used because the `~/.config` and `~/.config/Code`\n" + + "directories are private by default.\n" + + "\n" + + "Apply the changes:\n" + + "\n" + + " chezmoi apply -v\n" + + "\n" + + "Now, when the program modifies its configuration file it will modify the file in\n" + + "the source state instead.\n" + + "\n" + + "## Handle different file locations on different systems with the same contents\n" + + "\n" + + "If you want to have the same file contents in different locations on different\n" + + "systems, but maintain only a single file in your source state, you can use\n" + + "a shared template.\n" + + "\n" + + "Create the common file in the `.chezmoitemplates` directory in the source state. For\n" + + "example, create `.chezmoitemplates/file.conf`. The contents of this file are\n" + + "available in templates with the `template *name*` function where *name* is the\n" + + "name of the file.\n" + + "\n" + + "Then create files for each system, for example `Library/Application\n" + + "Support/App/file.conf.tmpl` for macOS and `dot_config/app/file.conf.tmpl` for\n" + + "Linux. Both template files should contain `{{- template \"file.conf\" -}}`.\n" + + "\n" + + "Finally, tell chezmoi to ignore files where they are not needed by adding lines\n" + + "to your `.chezmoiignore` file, for example:\n" + + "\n" + + "```\n" + + "{{ if ne .chezmoi.os \"darwin\" }}\n" + + "Library/Application Support/App/file.conf\n" + + "{{ end }}\n" + + "{{ if ne .chezmoi.os \"linux\" }}\n" + + ".config/app/file.conf\n" + + "{{ end }}\n" + + "```\n" + + "\n" + + "## Keep data private\n" + + "\n" + + "chezmoi automatically detects when files and directories are private when adding\n" + + "them by inspecting their permissions. Private files and directories are stored\n" + + "in `~/.local/share/chezmoi` as regular, public files with permissions `0644` and\n" + + "the name prefix `private_`. For example:\n" + + "\n" + + " chezmoi add ~/.netrc\n" + + "\n" + + "will create `~/.local/share/chezmoi/private_dot_netrc` (assuming `~/.netrc` is\n" + + "not world- or group- readable, as it should be). This file is still private\n" + + "because `~/.local/share/chezmoi` is not group- or world- readable or executable.\n" + + "chezmoi checks that the permissions of `~/.local/share/chezmoi` are `0700` on\n" + + "every run and will print a warning if they are not.\n" + + "\n" + + "It is common that you need to store access tokens in config files, e.g. a\n" + + "[GitHub access\n" + + "token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/).\n" + + "There are several ways to keep these tokens secure, and to prevent them leaving\n" + + "your machine.\n" + + "\n" + + "### Use Bitwarden to keep your secrets\n" + + "\n" + + "chezmoi includes support for [Bitwarden](https://bitwarden.com/) using the\n" + + "[Bitwarden CLI](https://github.com/bitwarden/cli) to expose data as a template\n" + + "function.\n" + + "\n" + + "Log in to Bitwarden using:\n" + + "\n" + + " bw login \n" + + "\n" + + "Unlock your Bitwarden vault:\n" + + "\n" + + " bw unlock\n" + + "\n" + + "Set the `BW_SESSION` environment variable, as instructed.\n" + + "\n" + + "The structured data from `bw get` is available as the `bitwarden` template\n" + + "function in your config files, for example:\n" + + "\n" + + " username = {{ (bitwarden \"item\" \"example.com\").login.username }}\n" + + " password = {{ (bitwarden \"item\" \"example.com\").login.password }}\n" + + "\n" + + "Custom fields can be accessed with the `bitwardenFields` template function. For\n" + + "example, if you have a custom field named `token` you can retrieve its value\n" + + "with:\n" + + "\n" + + " {{ (bitwardenFields \"item\" \"example.com\").token.value }}\n" + + "\n" + + "### Use gopass to keep your secrets\n" + + "\n" + + "chezmoi includes support for [gopass](https://www.gopass.pw/) using the gopass CLI.\n" + + "\n" + + "The first line of the output of `gopass show ` is available as the\n" + + "`gopass` template function, for example:\n" + + "\n" + + " {{ gopass \"\" }}\n" + + "\n" + + "### Use gpg to keep your secrets\n" + + "\n" + + "chezmoi supports encrypting files with [gpg](https://www.gnupg.org/). Encrypted\n" + + "files are stored in the source state and automatically be decrypted when\n" + + "generating the target state or printing a file's contents with `chezmoi cat`.\n" + + "`chezmoi edit` will transparently decrypt the file before editing and re-encrypt\n" + + "it afterwards.\n" + + "\n" + + "#### Asymmetric (private/public-key) encryption\n" + + "\n" + + "Specify the encryption key to use in your configuration file (`chezmoi.toml`)\n" + + "with the `gpg.recipient` key:\n" + + "\n" + + " [gpg]\n" + + " recipient = \"...\"\n" + + "\n" + + "Add files to be encrypted with the `--encrypt` flag, for example:\n" + + "\n" + + " chezmoi add --encrypt ~/.ssh/id_rsa\n" + + "\n" + + "chezmoi will encrypt the file with:\n" + + "\n" + + " gpg --armor --recipient ${gpg.recipient} --encrypt\n" + + "\n" + + "and store the encrypted file in the source state. The file will automatically be\n" + + "decrypted when generating the target state.\n" + + "\n" + + "#### Symmetric encryption\n" + + "\n" + + "Specify symmetric encryption in your configuration file:\n" + + "\n" + + " [gpg]\n" + + " symmetric = true\n" + + "\n" + + "Add files to be encrypted with the `--encrypt` flag, for example:\n" + + "\n" + + " chezmoi add --encrypt ~/.ssh/id_rsa\n" + + "\n" + + "chezmoi will encrypt the file with:\n" + + "\n" + + " gpg --armor --symmetric\n" + + "\n" + + "### Use KeePassXC to keep your secrets\n" + + "\n" + + "chezmoi includes support for [KeePassXC](https://keepassxc.org) using the\n" + + "KeePassXC CLI (`keepassxc-cli`) to expose data as a template function.\n" + + "\n" + + "Provide the path to your KeePassXC database in your configuration file:\n" + + "\n" + + " [keepassxc]\n" + + " database = \"/home/user/Passwords.kdbx\"\n" + + "\n" + + "The structured data from `keepassxc-cli show $database` is available as the\n" + + "`keepassxc` template function in your config files, for example:\n" + + "\n" + + " username = {{ (keepassxc \"example.com\").UserName }}\n" + + " password = {{ (keepassxc \"example.com\").Password }}\n" + + "\n" + + "Additional attributes are available through the `keepassxcAttribute` function.\n" + + "For example, if you have an entry called `SSH Key` with an additional attribute\n" + + "called `private-key`, its value is available as:\n" + + "\n" + + " {{ keepassxcAttribute \"SSH Key\" \"private-key\" }}\n" + + "\n" + + "### Use a keyring to keep your secrets\n" + + "\n" + + "chezmoi includes support for Keychain (on macOS), GNOME Keyring (on Linux), and\n" + + "Windows Credentials Manager (on Windows) via the\n" + + "[`zalando/go-keyring`](https://github.com/zalando/go-keyring) library.\n" + + "\n" + + "Set values with:\n" + + "\n" + + " $ chezmoi keyring set --service= --user=\n" + + " Value: xxxxxxxx\n" + + "\n" + + "The value can then be used in templates using the `keyring` function which takes\n" + + "the service and user as arguments.\n" + + "\n" + + "For example, save a GitHub access token in keyring with:\n" + + "\n" + + " $ chezmoi keyring set --service=github --user=\n" + + " Value: xxxxxxxx\n" + + "\n" + + "and then include it in your `~/.gitconfig` file with:\n" + + "\n" + + " [github]\n" + + " user = \"{{ .github.user }}\"\n" + + " token = \"{{ keyring \"github\" .github.user }}\"\n" + + "\n" + + "You can query the keyring from the command line:\n" + + "\n" + + " chezmoi keyring get --service=github --user=\n" + + "\n" + + "### Use LastPass to keep your secrets\n" + + "\n" + + "chezmoi includes support for [LastPass](https://lastpass.com) using the\n" + + "[LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html) to expose\n" + + "data as a template function.\n" + + "\n" + + "Log in to LastPass using:\n" + + "\n" + + " lpass login \n" + + "\n" + + "Check that `lpass` is working correctly by showing password data:\n" + + "\n" + + " lpass show --json \n" + + "\n" + + "where `` is a [LastPass Entry\n" + + "Specification](https://lastpass.github.io/lastpass-cli/lpass.1.html#_entry_specification).\n" + + "\n" + + "The structured data from `lpass show --json id` is available as the `lastpass`\n" + + "template function. The value will be an array of objects. You can use the\n" + + "`index` function and `.Field` syntax of the `text/template` language to extract\n" + + "the field you want. For example, to extract the `password` field from first the\n" + + "\"GitHub\" entry, use:\n" + + "\n" + + " githubPassword = \"{{ (index (lastpass \"GitHub\") 0).password }}\"\n" + + "\n" + + "chezmoi automatically parses the `note` value of the Lastpass entry as\n" + + "colon-separated key-value pairs, so, for example, you can extract a private SSH\n" + + "key like this:\n" + + "\n" + + " {{ (index (lastpass \"SSH\") 0).note.privateKey }}\n" + + "\n" + + "Keys in the `note` section written as `CamelCase Words` are converted to\n" + + "`camelCaseWords`.\n" + + "\n" + + "If the `note` value does not contain colon-separated key-value pairs, then you\n" + + "can use `lastpassRaw` to get its raw value, for example:\n" + + "\n" + + " {{ (index (lastpassRaw \"SSH Private Key\") 0).note }}\n" + + "\n" + + "### Use 1Password to keep your secrets\n" + + "\n" + + "chezmoi includes support for [1Password](https://1password.com/) using the\n" + + "[1Password CLI](https://support.1password.com/command-line-getting-started/) to\n" + + "expose data as a template function.\n" + + "\n" + + "Log in and get a session using:\n" + + "\n" + + " eval $(op signin .1password.com )\n" + + "\n" + + "The output of `op get item ` is available as the `onepassword` template\n" + + "function. chezmoi parses the JSON output and returns it as structured data. For\n" + + "example, if the output of `op get item \"\"` is:\n" + + "\n" + + " {\n" + + " \"uuid\": \"\",\n" + + " \"details\": {\n" + + " \"password\": \"xxx\"\n" + + " }\n" + + " }\n" + + "\n" + + "Then you can access `details.password` with the syntax:\n" + + "\n" + + " {{ (onepassword \"\").details.password }}\n" + + "\n" + + "Login details fields can be retrieved with the `onepasswordDetailsFields`\n" + + "function, for example:\n" + + "\n" + + " {{- (onepasswordDetailsFields \"uuid\").password.value }}\n" + + "\n" + + "Documents can be retrieved with:\n" + + "\n" + + " {{- onepasswordDocument \"uuid\" -}}\n" + + "\n" + + "Note the extra `-` after the opening `{{` and before the closing `}}`. This\n" + + "instructs the template language to remove and whitespace before and after the\n" + + "substitution. This removes any trailing newline added by your editor when saving\n" + + "the template.\n" + + "\n" + + "### Use pass to keep your secrets\n" + + "\n" + + "chezmoi includes support for [pass](https://www.passwordstore.org/) using the\n" + + "pass CLI.\n" + + "\n" + + "The first line of the output of `pass show ` is available as the\n" + + "`pass` template function, for example:\n" + + "\n" + + " {{ pass \"\" }}\n" + + "\n" + + "### Use Vault to keep your secrets\n" + + "\n" + + "chezmoi includes support for [Vault](https://www.vaultproject.io/) using the\n" + + "[Vault CLI](https://www.vaultproject.io/docs/commands/) to expose data as a\n" + + "template function.\n" + + "\n" + + "The vault CLI needs to be correctly configured on your machine, e.g. the\n" + + "`VAULT_ADDR` and `VAULT_TOKEN` environment variables must be set correctly.\n" + + "Verify that this is the case by running:\n" + + "\n" + + " vault kv get -format=json \n" + + "\n" + + "The structured data from `vault kv get -format=json` is available as the `vault`\n" + + "template function. You can use the `.Field` syntax of the `text/template`\n" + + "language to extract the data you want. For example:\n" + + "\n" + + " {{ (vault \"\").data.data.password }}\n" + + "\n" + + "### Use a generic tool to keep your secrets\n" + + "\n" + + "You can use any command line tool that outputs secrets either as a string or in\n" + + "JSON format. Choose the binary by setting `genericSecret.command` in your\n" + + "configuration file. You can then invoke this command with the `secret` and\n" + + "`secretJSON` template functions which return the raw output and JSON-decoded\n" + + "output respectively. All of the above secret managers can be supported in this\n" + + "way:\n" + + "\n" + + "| Secret Manager | `genericSecret.command` | Template skeleton |\n" + + "| --------------- | ----------------------- | ------------------------------------------------- |\n" + + "| 1Password | `op` | `{{ secretJSON \"get\" \"item\" }}` |\n" + + "| Bitwarden | `bw` | `{{ secretJSON \"get\" }}` |\n" + + "| Hashicorp Vault | `vault` | `{{ secretJSON \"kv\" \"get\" \"-format=json\" }}` |\n" + + "| LastPass | `lpass` | `{{ secretJSON \"show\" \"--json\" }}` |\n" + + "| KeePassXC | `keepassxc-cli` | Not possible (interactive command only) |\n" + + "| pass | `pass` | `{{ secret \"show\" }}` |\n" + + "\n" + + "### Use templates variables to keep your secrets\n" + + "\n" + + "Typically, `~/.config/chezmoi/chezmoi.toml` is not checked in to version control\n" + + "and has permissions 0600. You can store tokens as template values in the `data`\n" + + "section. For example, if your `~/.config/chezmoi/chezmoi.toml` contains:\n" + + "\n" + + " [data]\n" + + " [data.github]\n" + + " user = \"\"\n" + + " token = \"\"\n" + + "\n" + + "Your `~/.local/share/chezmoi/private_dot_gitconfig.tmpl` can then contain:\n" + + "\n" + + " {{- if (index . \"github\") }}\n" + + " [github]\n" + + " user = \"{{ .github.user }}\"\n" + + " token = \"{{ .github.token }}\"\n" + + " {{- end }}\n" + + "\n" + + "Any config files containing tokens in plain text should be private (permissions\n" + + "`0600`).\n" + + "\n" + + "## Use scripts to perform actions\n" + + "\n" + + "### Understand how scripts work\n" + + "\n" + + "chezmoi supports scripts, which are executed when you run `chezmoi apply`. The\n" + + "scripts can either run every time you run `chezmoi apply`, or only when their\n" + + "contents have changed.\n" + + "\n" + + "In verbose mode, the script's contents will be printed before executing it. In\n" + + "dry-run mode, the script is not executed.\n" + + "\n" + + "Scripts are any file in the source directory with the prefix `run_`, and are\n" + + "executed in alphabetical order. Scripts that should only be run when their\n" + + "contents change have the prefix `run_once_`.\n" + + "\n" + + "Scripts break chezmoi's declarative approach, and as such should be used\n" + + "sparingly. Any script should be idempotent, even `run_once_` scripts.\n" + + "\n" + + "Scripts must be created manually in the source directory, typically by running\n" + + "`chezmoi cd` and then creating a file with a `run_` prefix. Scripts are executed\n" + + "directly using `exec` and must include a shebang line or be executable binaries.\n" + + "There is no need to set the executable bit on the script.\n" + + "\n" + + "Scripts with the suffix `.tmpl` are treated as templates, with the usual\n" + + "template variables available. If, after executing the template, the result is\n" + + "only whitespace or an empty string, then the script is not executed. This is\n" + + "useful for disabling scripts.\n" + + "\n" + + "### Install packages with scripts\n" + + "\n" + + "Change to the source directory and create a file called\n" + + "`run_once_install-packages.sh`:\n" + + "\n" + + " chezmoi cd\n" + + " $EDITOR run_once_install-packages.sh\n" + + "\n" + + "In this file create your package installation script, e.g.\n" + + "\n" + + " #!/bin/sh\n" + + " sudo apt install ripgrep\n" + + "\n" + + "The next time you run `chezmoi apply` or `chezmoi update` this script will be\n" + + "run. As it has the `run_once_` prefix, it will not be run again unless its\n" + + "contents change, for example if you add more packages to be installed.\n" + + "\n" + + "This script can also be a template. For example, if you create\n" + + "`run_once_install-packages.sh.tmpl` with the contents:\n" + + "\n" + + " {{ if eq .chezmoi.os \"linux\" -}}\n" + + " #!/bin/sh\n" + + " sudo apt install ripgrep\n" + + " {{ else if eq .chezmoi.os \"darwin\" -}}\n" + + " #!/bin/sh\n" + + " brew install ripgrep\n" + + " {{ end -}}\n" + + "\n" + + "This will install `ripgrep` on both Debian/Ubuntu Linux systems and macOS.\n" + + "\n" + + "## Use chezmoi with GitHub Codespaces, Visual Studio Codespaces, Visual Studio Code Remote - Containers\n" + + "\n" + + "The following assumes you are using chezmoi 1.8.4 or later. It does not work\n" + + "with earlier versions of chezmoi.\n" + + "\n" + + "You can use chezmoi to manage your dotfiles in [GitHub\n" + + "Codespaces](https://docs.github.com/en/github/developing-online-with-codespaces/personalizing-codespaces-for-your-account),\n" + + "[Visual Studio\n" + + "Codespaces](https://docs.microsoft.com/en/visualstudio/codespaces/reference/personalizing),\n" + + "and [Visual Studio Code Remote -\n" + + "Containers](https://code.visualstudio.com/docs/remote/containers#_personalizing-with-dotfile-repositories).\n" + + "\n" + + "For a quick start, you can clone the [`chezmoi/dotfiles`\n" + + "repository](https://github.com/chezmoi/dotfiles) which supports Codespaces out\n" + + "of the box.\n" + + "\n" + + "The workflow is different to using chezmoi on a new machine, notably:\n" + + "* These systems will automatically clone your `dotfiles` repo to `~/dotfiles`,\n" + + " so there is no need to clone your repo yourself.\n" + + "* The installation script must be non-interactive.\n" + + "* When running in a Codespace, the environment variable `CODESPACES` will be set\n" + + " to `true`. You can read its value with the [`env` template\n" + + " function](http://masterminds.github.io/sprig/os.html).\n" + + "\n" + + "First, if you are using a chezmoi configuration file template, ensure that it is\n" + + "non-interactive when running in codespaces, for example, `.chezmoi.toml.tmpl`\n" + + "might contain:\n" + + "\n" + + "```\n" + + "{{- $codespaces:= env \"CODESPACES\" | not | not -}}\n" + + "sourceDir = \"{{ .chezmoi.sourceDir }}\"\n" + + "\n" + + "[data]\n" + + " name = \"Your name\"\n" + + " codespaces = {{ $codespaces }}\n" + + "{{- if $codespaces }}{{/* Codespaces dotfiles setup is non-interactive, so set an email address */}}\n" + + " email = \"your@email.com\"\n" + + "{{- else }}{{/* Interactive setup, so prompt for an email address */}}\n" + + " email = \"{{ promptString \"email\" }}\"\n" + + "{{- end }}\n" + + "```\n" + + "\n" + + "This sets the `codespaces` template variable, so you don't have to repeat `(env\n" + + "\"CODESPACES\")` in your templates. It also sets the `sourceDir` configuration to\n" + + "the `--source` argument passed in `chezmoi init`.\n" + + "\n" + + "Second, create an `install.sh` script that installs chezmoi and your dotfiles:\n" + + "\n" + + "```sh\n" + + "#!/bin/sh\n" + + "\n" + + "set -e # -e: exit on error\n" + + "\n" + + "if [ ! \"$(command -v chezmoi)\" ]; then\n" + + " bin_dir=\"$HOME/.local/bin\"\n" + + " chezmoi=\"$bin_dir/chezmoi\"\n" + + " if [ \"$(command -v curl)\" ]; then\n" + + " sh -c \"$(curl -fsSL https://git.io/chezmoi)\" -- -b \"$bin_dir\"\n" + + " elif [ \"$(command -v wget)\" ]; then\n" + + " sh -c \"$(wget -qO- https://git.io/chezmoi)\" -- -b \"$bin_dir\"\n" + + " else\n" + + " echo \"To install chezmoi, you must have curl or wget installed.\" >&2\n" + + " exit 1\n" + + " fi\n" + + "else\n" + + " chezmoi=chezmoi\n" + + "fi\n" + + "\n" + + "# POSIX way to get script's dir: https://stackoverflow.com/a/29834779/12156188\n" + + "script_dir=\"$(cd -P -- \"$(dirname -- \"$(command -v -- \"$0\")\")\" && pwd -P)\"\n" + + "# exec: replace current process with chezmoi init\n" + + "exec \"$chezmoi\" init --apply \"--source=$script_dir\"\n" + + "```\n" + + "\n" + + "Ensure that this file is executable (`chmod a+x install.sh`), and add\n" + + "`install.sh` to your `.chezmoiignore` file.\n" + + "\n" + + "It installs the latest version of chezmoi in `~/.local/bin` if needed, and then\n" + + "`chezmoi init ...` invokes chezmoi to create its configuration file and\n" + + "initialize your dotfiles. `--apply` tells chezmoi to apply the changes\n" + + "immediately, and `--source=...` tells chezmoi where to find the cloned\n" + + "`dotfiles` repo, which in this case is the same folder in which the script is\n" + + "running from.\n" + + "\n" + + "If you do not use a chezmoi configuration file template you can use `chezmoi\n" + + "apply --source=$HOME/dotfiles` instead of `chezmoi init ...` in `install.sh`.\n" + + "\n" + + "Finally, modify any of your templates to use the `codespaces` variable if\n" + + "needed. For example, to install `vim-gtk` on Linux but not in Codespaces, your\n" + + "`run_once_install-packages.sh.tmpl` might contain:\n" + + "\n" + + "```\n" + + "{{- if (and (eq .chezmoi.os \"linux\")) (not .codespaces))) -}}\n" + + "#!/bin/sh\n" + + "sudo apt install -y vim-gtk\n" + + "{{- end -}}\n" + + "```\n" + + "\n" + + "## Detect Windows Subsystem for Linux (WSL)\n" + + "\n" + + "WSL can be detected by looking for the string `Microsoft` in\n" + + "`/proc/kernel/osrelease`, which is available in the template variable\n" + + "`.chezmoi.kernel.osrelease`, for example:\n" + + "\n" + + "WSL 1:\n" + + "```\n" + + "{{ if (contains \"Microsoft\" .chezmoi.kernel.osrelease) }}\n" + + "# WSL-specific code\n" + + "{{ end }}\n" + + "```\n" + + "\n" + + "WSL 2:\n" + + "```\n" + + "{{ if (contains \"microsoft\" .chezmoi.kernel.osrelease) }}\n" + + "# WSL-specific code\n" + + "{{ end }}\n" + + "```\n" + + "\n" + + "WSL 2 since version 4.19.112:\n" + + "```\n" + + "{{ if (contains \"microsoft-WSL2\" .chezmoi.kernel.osrelease) }}\n" + + "# WSL-specific code\n" + + "{{ end }}\n" + + "```\n" + + "\n" + + "## Run a PowerShell script as admin on Windows\n" + + "\n" + + "Put the following at the top of your script:\n" + + "\n" + + "```powershell\n" + + "# Self-elevate the script if required\n" + + "if (-Not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) {\n" + + " if ([int](Get-CimInstance -Class Win32_OperatingSystem | Select-Object -ExpandProperty BuildNumber) -ge 6000) {\n" + + " $CommandLine = \"-NoExit -File `\"\" + $MyInvocation.MyCommand.Path + \"`\" \" + $MyInvocation.UnboundArguments\n" + + " Start-Process -FilePath PowerShell.exe -Verb Runas -ArgumentList $CommandLine\n" + + " Exit\n" + + " }\n" + + "}\n" + + "```\n" + + "\n" + + "## Import archives\n" + + "\n" + + "It is occasionally useful to import entire archives of configuration into your\n" + + "source state. The `import` command does this. For example, to import the latest\n" + + "version\n" + + "[`github.com/robbyrussell/oh-my-zsh`](https://github.com/robbyrussell/oh-my-zsh)\n" + + "to `~/.oh-my-zsh` run:\n" + + "\n" + + " curl -s -L -o oh-my-zsh-master.tar.gz https://github.com/robbyrussell/oh-my-zsh/archive/master.tar.gz\n" + + " chezmoi import --strip-components 1 --destination ~/.oh-my-zsh oh-my-zsh-master.tar.gz\n" + + "\n" + + "Note that this only updates the source state. You will need to run\n" + + "\n" + + " chezmoi apply\n" + + "\n" + + "to update your destination directory.\n" + + "\n" + + "## Export archives\n" + + "\n" + + "chezmoi can create an archive containing the target state. This can be useful\n" + + "for generating target state on a different machine or for simply inspecting the\n" + + "target state. A particularly useful command is:\n" + + "\n" + + " chezmoi archive | tar tvf -\n" + + "\n" + + "which lists all the targets in the target state.\n" + + "\n" + + "## Use a non-git version control system\n" + + "\n" + + "By default, chezmoi uses git, but you can use any version control system of your\n" + + "choice. In your config file, specify the command to use. For example, to use\n" + + "Mercurial specify:\n" + + "\n" + + " [sourceVCS]\n" + + " command = \"hg\"\n" + + "\n" + + "The source VCS command is used in the chezmoi commands `init`, `source`, and\n" + + "`update`, and support for VCSes other than git is limited but easy to add. If\n" + + "you'd like to see your VCS better supported, please [open an issue on\n" + + "GitHub](https://github.com/twpayne/chezmoi/issues/new/choose).\n" + + "\n" + + "## Customize the `diff` command\n" + + "\n" + + "By default, chezmoi uses a built-in diff. You can change the format, and/or pipe\n" + + "the output into a pager of your choice. For example, to use\n" + + "[`diff-so-fancy`](https://github.com/so-fancy/diff-so-fancy) specify:\n" + + "\n" + + " [diff]\n" + + " format = \"git\"\n" + + " pager = \"diff-so-fancy\"\n" + + "\n" + + "The format can also be set with the `--format` option to the `diff` command, and\n" + + "the pager can be disabled using `--no-pager`.\n" + + "\n" + + "## Use a merge tool other than vimdiff\n" + + "\n" + + "By default, chezmoi uses vimdiff, but you can use any merge tool of your choice.\n" + + "In your config file, specify the command and args to use. For example, to use\n" + + "neovim's diff mode specify:\n" + + "\n" + + " [merge]\n" + + " command = \"nvim\"\n" + + " args = \"-d\"\n" + + "\n" + + "## Migrate from a dotfile manager that uses symlinks\n" + + "\n" + + "Many dotfile managers replace dotfiles with symbolic links to files in a common\n" + + "directory. If you `chezmoi add` such a symlink, chezmoi will add the symlink,\n" + + "not the file. To assist with migrating from symlink-based systems, use the\n" + + "`--follow` option to `chezmoi add`, for example:\n" + + "\n" + + " chezmoi add --follow ~/.bashrc\n" + + "\n" + + "This will tell `chezmoi add` that the target state of `~/.bashrc` is the target\n" + + "of the `~/.bashrc` symlink, rather than the symlink itself. When you run\n" + + "`chezmoi apply`, chezmoi will replace the `~/.bashrc` symlink with the file\n" + + "contents.\n" + + "\n") + assets["docs/INSTALL.md"] = []byte("" + + "# chezmoi Install Guide\n" + + "\n" + + "\n" + + "* [One-line binary install](#one-line-binary-install)\n" + + "* [One-line package install](#one-line-package-install)\n" + + "* [Pre-built Linux packages](#pre-built-linux-packages)\n" + + "* [Pre-built binaries](#pre-built-binaries)\n" + + "* [All pre-built Linux packages and binaries](#all-pre-built-linux-packages-and-binaries)\n" + + "* [From source](#from-source)\n" + + "* [Upgrading](#upgrading)\n" + + "\n" + + "## One-line binary install\n" + + "\n" + + "Install the correct binary for your operating system and architecture in `./bin`\n" + + "with a single command.\n" + + "\n" + + " curl -sfL https://git.io/chezmoi | sh\n" + + "\n" + + "## One-line package install\n" + + "\n" + + "Install chezmoi with a single command.\n" + + "\n" + + "| OS | Method | Command |\n" + + "| ------------ | ---------- | ------------------------------------------------------------------------------------------- |\n" + + "| Linux | snap | `snap install chezmoi --classic` |\n" + + "| Linux | Linuxbrew | `brew install chezmoi` |\n" + + "| Alpine Linux | apk | `apk add chezmoi` |\n" + + "| Arch Linux | pacman | `pacman -S chezmoi` |\n" + + "| Guix Linux | guix | `guix install chezmoi` |\n" + + "| NixOS Linux | nix-env | `nix-env -i chezmoi` |\n" + + "| Void Linux | xbps | `xbps-install -S chezmoi` |\n" + + "| macOS | Homebrew | `brew install chezmoi` |\n" + + "| macOS | MacPorts | `sudo port install chezmoi` |\n" + + "| Windows | Scoop | `scoop bucket add twpayne https://github.com/twpayne/scoop-bucket && scoop install chezmoi` |\n" + + "| Windows | Chocolatey | `choco install chezmoi` |\n" + + "\n" + + "## Pre-built Linux packages\n" + + "\n" + + "Download a package for your operating system and architecture and install it\n" + + "with your package manager.\n" + + "\n" + + "| Distribution | Architectures | Package |\n" + + "| ------------ | --------------------------------------------------------- | ----------------------------------------------------------- |\n" + + "| Alpine | `386`, `amd64`, `arm64`, `arm`, `ppc64`, `ppc64le` | [`apk`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| Debian | `amd64`, `arm64`, `armel`, `i386`, `ppc64`, `ppc64le` | [`deb`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| RedHat | `aarch64`, `armhfp`, `i686`, `ppc64`, `ppc64le`, `x86_64` | [`rpm`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| OpenSUSE | `aarch64`, `armhfp`, `i686`, `ppc64`, `ppc64le`, `x86_64` | [`rpm`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| Ubuntu | `amd64`, `arm64`, `armel`, `i386`, `ppc64`, `ppc64le` | [`deb`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "\n" + + "## Pre-built binaries\n" + + "\n" + + "Download an archive for your operating system containing a pre-built binary,\n" + + "documentation, and shell completions.\n" + + "\n" + + "| OS | Architectures | Archive |\n" + + "| ---------- | --------------------------------------------------- | -------------------------------------------------------------- |\n" + + "| FreeBSD | `amd64`, `arm`, `arm64`, `i386` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| Linux | `amd64`, `arm`, `arm64`, `i386`, `ppc64`, `ppc64le` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| macOS | `amd64` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| OpenBSD | `amd64`, `arm`, `arm64`, `i386` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| Windows | `amd64`, `i386` | [`zip`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "\n" + + "## All pre-built Linux packages and binaries\n" + + "\n" + + "All pre-built binaries and packages can be found on the [chezmoi GitHub releases\n" + + "page](https://github.com/twpayne/chezmoi/releases/latest).\n" + + "\n" + + "## From source\n" + + "\n" + + "Download, build, and install chezmoi for your system:\n" + + "\n" + + " cd $(mktemp -d)\n" + + " git clone --depth=1 https://github.com/twpayne/chezmoi.git\n" + + " cd chezmoi\n" + + " go install\n" + + "\n" + + "Building chezmoi requires Go 1.14 or later.\n" + + "\n" + + "## Upgrading\n" + + "\n" + + "If you have installed a pre-built binary of chezmoi, you can upgrade it to the\n" + + "latest release with:\n" + + "\n" + + " chezmoi upgrade\n" + + "\n" + + "This will re-use whichever mechanism you used to install chezmoi to install the\n" + + "latest release.\n" + + "\n") + assets["docs/MEDIA.md"] = []byte("" + + "# chezmoi in the media\n" + + "\n" + + "\n" + + "\n" + + "Recommended article: [Fedora Magazine: Take back your dotfiles with Chezmoi](https://fedoramagazine.org/take-back-your-dotfiles-with-chezmoi/)\n" + + "\n" + + "| Date | Version | Format | Link |\n" + + "| ---------- | ------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n" + + "| 2020-11-06 | 1.8.8 | Text | [Chezmoi – Securely Manage dotfiles across multiple machines](https://computingforgeeks.com/chezmoi-manage-dotfiles-across-multiple-machines/) |\n" + + "| 2020-11-05 | 1.8.8 | Text | [Using chezmoi to manage dotfiles](https://pashinskikh.de/posts/chezmoi/) |\n" + + "| 2020-10-05 | 1.8.6 | Text | [Dotfiles with /Chezmoi/](https://blog.lazkani.io/posts/backup/dotfiles-with-chezmoi/) |\n" + + "| 2020-08-13 | 1.8.3 | Text | [Using BitWarden and Chezmoi to manage SSH keys](https://www.jx0.uk/chezmoi/bitwarden/unix/ssh/2020/08/13/bitwarden-chezmoi-ssh-key.html) |\n" + + "| 2020-08-09 | 1.8.3 | Text | [Automating and testing dotfiles](https://seds.nl/posts/automating-and-testing-dotfiles/) |\n" + + "| 2020-08-03 | 1.8.3 | Text | [Automating a Linux in Windows Dev Setup](https://matt.aimonetti.net/posts/2020-08-automating-a-linux-in-windows-dev-setup/) |\n" + + "| 2020-07-06 | 1.8.3 | Video | [chezmoi: Manage your dotfiles across multiple machines, securely](https://www.youtube.com/watch?v=JrCMCdvoMAw). |\n" + + "| 2020-07-03 | 1.8.3 | Text | [Feeling at home in a LXD container](https://ubuntu.com/blog/feeling-at-home-in-a-lxd-container) |\n" + + "| 2020-06-15 | 1.8.2 | Text | [Dotfiles management using chezmoi - How I Use Linux Desktop at Work Part5](https://blog.benoitj.ca/2020-06-15-how-i-use-linux-desktop-at-work-part5-dotfiles/) |\n" + + "| 2020-04-27 | 1.8.0 | Text | [Managing my dotfiles with chezmoi](http://blog.emilieschario.com/post/managing-my-dotfiles-with-chezmoi/) |\n" + + "| 2020-04-16 | 1.17.19 | Text (FR) | [Chezmoi, visite guidée](https://blog.wescale.fr/2020/04/16/chezmoi-visite-guidee/) |\n" + + "| 2020-04-03 | 1.7.17 | Text | [Fedora Magazine: Take back your dotfiles with Chezmoi](https://fedoramagazine.org/take-back-your-dotfiles-with-chezmoi/) |\n" + + "| 2020-04-01 | 1.7.17 | Text | [Managing dotfiles and secret with chezmoi](https://blog.arkey.fr/2020/04/01/manage_dotfiles_with_chezmoi/) |\n" + + "| 2020-03-12 | 1.7.16 | Video | [Managing Dotfiles with ChezMoi](https://www.youtube.com/watch?v=HXx6ugA98Qo) |\n" + + "| 2019-11-20 | 1.7.2 | Audio/video | [FLOSS weekly episode 556: chezmoi](https://twit.tv/shows/floss-weekly/episodes/556) |\n" + + "| 2019-01-10 | 0.0.11 | Text | [Linux Fu: The kitchen sync](https://hackaday.com/2019/01/10/linux-fu-the-kitchen-sync/) |\n" + + "\n" + + "To add your article to this page please either [open an\n" + + "issue](https://github.com/twpayne/chezmoi/issues/new/choose) or submit a pull\n" + + "request that modifies this file\n" + + "([`docs/MEDIA.md`](https://github.com/twpayne/chezmoi/blob/master/docs/MEDIA.md)).\n" + + "\n") + assets["docs/QUICKSTART.md"] = []byte("" + + "# chezmoi Quick Start Guide\n" + + "\n" + + "\n" + + "* [Concepts](#concepts)\n" + + "* [Start using chezmoi on your current machine](#start-using-chezmoi-on-your-current-machine)\n" + + "* [Using chezmoi across multiple machines](#using-chezmoi-across-multiple-machines)\n" + + "* [Next steps](#next-steps)\n" + + "\n" + + "## Concepts\n" + + "\n" + + "chezmoi stores the desired state of your dotfiles in the directory\n" + + "`~/.local/share/chezmoi`. When you run `chezmoi apply`, chezmoi calculates the\n" + + "desired contents and permissions for each dotfile and then makes any changes\n" + + "necessary so that your dotfiles match that state.\n" + + "\n" + + "## Start using chezmoi on your current machine\n" + + "\n" + + "Initialize chezmoi:\n" + + "\n" + + " chezmoi init\n" + + "\n" + + "This will create a new git repository in `~/.local/share/chezmoi` with\n" + + "permissions `0700` where chezmoi will store the source state. chezmoi only\n" + + "modifies files in the working copy. It is your responsibility to commit changes.\n" + + "\n" + + "Manage an existing file with chezmoi:\n" + + "\n" + + " chezmoi add ~/.bashrc\n" + + "\n" + + "This will copy `~/.bashrc` to `~/.local/share/chezmoi/dot_bashrc`. If you want\n" + + "to add a whole folder to chezmoi, you have to add the `-r` argument after `add`.\n" + + "\n" + + "Edit the source state:\n" + + "\n" + + " chezmoi edit ~/.bashrc\n" + + "\n" + + "This will open `~/.local/share/chezmoi/dot_bashrc` in your `$EDITOR`. Make some\n" + + "changes and save them.\n" + + "\n" + + "See what changes chezmoi would make:\n" + + "\n" + + " chezmoi diff\n" + + "\n" + + "Apply the changes:\n" + + "\n" + + " chezmoi -v apply\n" + + "\n" + + "All chezmoi commands accept the `-v` (verbose) flag to print out exactly what\n" + + "changes they will make to the file system, and the `-n` (dry run) flag to not\n" + + "make any actual changes. The combination `-n` `-v` is very useful if you want to\n" + + "see exactly what changes would be made.\n" + + "\n" + + "Finally, open a shell in the source directory, commit your changes, and return\n" + + "to where you were:\n" + + "\n" + + " chezmoi cd\n" + + " git add dot_bashrc\n" + + " git commit -m \"Add .bashrc\"\n" + + " exit\n" + + "\n" + + "## Using chezmoi across multiple machines\n" + + "\n" + + "Clone the git repo in `~/.local/share/chezmoi` to a hosted Git service, e.g.\n" + + "[GitHub](https://github.com), [GitLab](https://gitlab.com), or\n" + + "[BitBucket](https://bitbucket.org). Many people call their dotfiles repo\n" + + "`dotfiles`. You can then setup chezmoi on a second machine:\n" + + "\n" + + " chezmoi init https://github.com/username/dotfiles.git\n" + + "\n" + + "This will check out the repo and any submodules and optionally create a chezmoi\n" + + "config file for you. It won't make any changes to your home directory until you\n" + + "run:\n" + + "\n" + + " chezmoi apply\n" + + "\n" + + "On any machine, you can pull and apply the latest changes from your repo with:\n" + + "\n" + + " chezmoi update\n" + + "\n" + + "## Next steps\n" + + "\n" + + "For a full list of commands run:\n" + + "\n" + + " chezmoi help\n" + + "\n" + + "chezmoi has much more functionality. Read the [how-to\n" + + "guide](https://github.com/twpayne/chezmoi/blob/master/docs/HOWTO.md) to explore.\n" + + "\n") + assets["docs/REFERENCE.md"] = []byte("" + + "# chezmoi Reference Manual\n" + + "\n" + + "Manage your dotfiles securely across multiple machines.\n" + + "\n" + + "\n" + + "* [Concepts](#concepts)\n" + + "* [Global command line flags](#global-command-line-flags)\n" + + " * [`--color` *value*](#--color-value)\n" + + " * [`-c`, `--config` *filename*](#-c---config-filename)\n" + + " * [`--debug`](#--debug)\n" + + " * [`-D`, `--destination` *directory*](#-d---destination-directory)\n" + + " * [`--follow`](#--follow)\n" + + " * [`-n`, `--dry-run`](#-n---dry-run)\n" + + " * [`-h`, `--help`](#-h---help)\n" + + " * [`-r`. `--remove`](#-r---remove)\n" + + " * [`-S`, `--source` *directory*](#-s---source-directory)\n" + + " * [`-v`, `--verbose`](#-v---verbose)\n" + + " * [`--version`](#--version)\n" + + "* [Configuration file](#configuration-file)\n" + + " * [Variables](#variables)\n" + + " * [Examples](#examples)\n" + + "* [Source state attributes](#source-state-attributes)\n" + + "* [Special files and directories](#special-files-and-directories)\n" + + " * [`.chezmoi..tmpl`](#chezmoiformattmpl)\n" + + " * [`.chezmoiignore`](#chezmoiignore)\n" + + " * [`.chezmoiremove`](#chezmoiremove)\n" + + " * [`.chezmoitemplates`](#chezmoitemplates)\n" + + " * [`.chezmoiversion`](#chezmoiversion)\n" + + "* [Commands](#commands)\n" + + " * [`add` *targets*](#add-targets)\n" + + " * [`apply` [*targets*]](#apply-targets)\n" + + " * [`archive`](#archive)\n" + + " * [`cat` *targets*](#cat-targets)\n" + + " * [`cd`](#cd)\n" + + " * [`chattr` *attributes* *targets*](#chattr-attributes-targets)\n" + + " * [`completion` *shell*](#completion-shell)\n" + + " * [`data`](#data)\n" + + " * [`diff` [*targets*]](#diff-targets)\n" + + " * [`docs` [*regexp*]](#docs-regexp)\n" + + " * [`doctor`](#doctor)\n" + + " * [`dump` [*targets*]](#dump-targets)\n" + + " * [`edit` [*targets*]](#edit-targets)\n" + + " * [`edit-config`](#edit-config)\n" + + " * [`execute-template` [*templates*]](#execute-template-templates)\n" + + " * [`forget` *targets*](#forget-targets)\n" + + " * [`git` [*arguments*]](#git-arguments)\n" + + " * [`help` *command*](#help-command)\n" + + " * [`hg` [*arguments*]](#hg-arguments)\n" + + " * [`init` [*repo*]](#init-repo)\n" + + " * [`import` *filename*](#import-filename)\n" + + " * [`manage` *targets*](#manage-targets)\n" + + " * [`managed`](#managed)\n" + + " * [`merge` *targets*](#merge-targets)\n" + + " * [`purge`](#purge)\n" + + " * [`remove` *targets*](#remove-targets)\n" + + " * [`rm` *targets*](#rm-targets)\n" + + " * [`secret`](#secret)\n" + + " * [`source` [*args*]](#source-args)\n" + + " * [`source-path` [*targets*]](#source-path-targets)\n" + + " * [`unmanage` *targets*](#unmanage-targets)\n" + + " * [`unmanaged`](#unmanaged)\n" + + " * [`update`](#update)\n" + + " * [`upgrade`](#upgrade)\n" + + " * [`verify` [*targets*]](#verify-targets)\n" + + "* [Editor configuration](#editor-configuration)\n" + + "* [Umask configuration](#umask-configuration)\n" + + "* [Template execution](#template-execution)\n" + + "* [Template variables](#template-variables)\n" + + "* [Template functions](#template-functions)\n" + + " * [`bitwarden` [*args*]](#bitwarden-args)\n" + + " * [`bitwardenFields` [*args*]](#bitwardenfields-args)\n" + + " * [`gopass` *gopass-name*](#gopass-gopass-name)\n" + + " * [`include` *filename*](#include-filename)\n" + + " * [`ioreg`](#ioreg)\n" + + " * [`joinPath` *elements*](#joinpath-elements)\n" + + " * [`keepassxc` *entry*](#keepassxc-entry)\n" + + " * [`keepassxcAttribute` *entry* *attribute*](#keepassxcattribute-entry-attribute)\n" + + " * [`keyring` *service* *user*](#keyring-service-user)\n" + + " * [`lastpass` *id*](#lastpass-id)\n" + + " * [`lastpassRaw` *id*](#lastpassraw-id)\n" + + " * [`lookPath` *file*](#lookpath-file)\n" + + " * [`onepassword` *uuid* [*vault-uuid*]](#onepassword-uuid-vault-uuid)\n" + + " * [`onepasswordDocument` *uuid* [*vault-uuid*]](#onepassworddocument-uuid-vault-uuid)\n" + + " * [`onepasswordDetailsFields` *uuid* [*vault-uuid*]](#onepassworddetailsfields-uuid-vault-uuid)\n" + + " * [`pass` *pass-name*](#pass-pass-name)\n" + + " * [`promptBool` *prompt*](#promptbool-prompt)\n" + + " * [`promptInt` *prompt*](#promptint-prompt)\n" + + " * [`promptString` *prompt*](#promptstring-prompt)\n" + + " * [`secret` [*args*]](#secret-args)\n" + + " * [`secretJSON` [*args*]](#secretjson-args)\n" + + " * [`stat` *name*](#stat-name)\n" + + " * [`vault` *key*](#vault-key)\n" + + "\n" + + "## Concepts\n" + + "\n" + + "chezmoi evaluates the source state for the current machine and then updates the\n" + + "destination directory, where:\n" + + "\n" + + "* The *source state* declares the desired state of your home directory,\n" + + " including templates and machine-specific configuration.\n" + + "\n" + + "* The *source directory* is where chezmoi stores the source state, by default\n" + + " `~/.local/share/chezmoi`.\n" + + "\n" + + "* The *target state* is the source state computed for the current machine.\n" + + "\n" + + "* The *destination directory* is the directory that chezmoi manages, by default\n" + + " `~`, your home directory.\n" + + "\n" + + "* A *target* is a file, directory, or symlink in the destination directory.\n" + + "\n" + + "* The *destination state* is the current state of all the targets in the\n" + + " destination directory.\n" + + "\n" + + "* The *config file* contains machine-specific configuration, by default it is\n" + + " `~/.config/chezmoi/chezmoi.toml`.\n" + + "\n" + + "## Global command line flags\n" + + "\n" + + "Command line flags override any values set in the configuration file.\n" + + "\n" + + "### `--color` *value*\n" + + "\n" + + "Colorize diffs, *value* can be `on`, `off`, `auto`, or any boolean-like value\n" + + "recognized by\n" + + "[`strconv.ParseBool`](https://pkg.go.dev/strconv?tab=doc#ParseBool). The default\n" + + "value is `auto` which will colorize diffs only if the the environment variable\n" + + "`NO_COLOR` is not set and stdout is a terminal.\n" + + "\n" + + "### `-c`, `--config` *filename*\n" + + "\n" + + "Read the configuration from *filename*.\n" + + "\n" + + "### `--debug`\n" + + "\n" + + "Log information helpful for debugging.\n" + + "\n" + + "### `-D`, `--destination` *directory*\n" + + "\n" + + "Use *directory* as the destination directory.\n" + + "\n" + + "### `--follow`\n" + + "\n" + + "If the last part of a target is a symlink, deal with what the symlink\n" + + "references, rather than the symlink itself.\n" + + "\n" + + "### `-n`, `--dry-run`\n" + + "\n" + + "Set dry run mode. In dry run mode, the destination directory is never modified.\n" + + "This is most useful in combination with the `-v` (verbose) flag to print changes\n" + + "that would be made without making them.\n" + + "\n" + + "### `-h`, `--help`\n" + + "\n" + + "Print help.\n" + + "\n" + + "### `-r`. `--remove`\n" + + "\n" + + "Also remove targets according to `.chezmoiremove`.\n" + + "\n" + + "### `-S`, `--source` *directory*\n" + + "\n" + + "Use *directory* as the source directory.\n" + + "\n" + + "### `-v`, `--verbose`\n" + + "\n" + + "Set verbose mode. In verbose mode, chezmoi prints the changes that it is making\n" + + "as approximate shell commands, and any differences in files between the target\n" + + "state and the destination set are printed as unified diffs.\n" + + "\n" + + "### `--version`\n" + + "\n" + + "Print the version of chezmoi, the commit at which it was built, and the build\n" + + "timestamp.\n" + + "\n" + + "## Configuration file\n" + + "\n" + + "chezmoi searches for its configuration file according to the [XDG Base Directory\n" + + "Specification](https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html)\n" + + "and supports all formats supported by\n" + + "[`github.com/spf13/viper`](https://github.com/spf13/viper), namely\n" + + "[JSON](https://www.json.org/json-en.html),\n" + + "[TOML](https://github.com/toml-lang/toml), [YAML](https://yaml.org/), macOS\n" + + "property file format, and [HCL](https://github.com/hashicorp/hcl). The basename\n" + + "of the config file is `chezmoi`, and the first config file found is used.\n" + + "\n" + + "### Variables\n" + + "\n" + + "The following configuration variables are available:\n" + + "\n" + + "| Section | Variable | Type | Default value | Description |\n" + + "| --------------- | ------------ | -------- | ------------------------- | --------------------------------------------------- |\n" + + "| Top level | `color` | string | `auto` | Colorize diffs |\n" + + "| | `data` | any | *none* | Template data |\n" + + "| | `destDir` | string | `~` | Destination directory |\n" + + "| | `dryRun` | bool | `false` | Dry run mode |\n" + + "| | `follow` | bool | `false` | Follow symlinks |\n" + + "| | `remove` | bool | `false` | Remove targets |\n" + + "| | `sourceDir` | string | `~/.local/share/chezmoi` | Source directory |\n" + + "| | `umask` | int | *from system* | Umask |\n" + + "| | `verbose` | bool | `false` | Verbose mode |\n" + + "| `bitwarden` | `command` | string | `bw` | Bitwarden CLI command |\n" + + "| `cd` | `args` | []string | *none* | Extra args to shell in `cd` command |\n" + + "| | `command` | string | *none* | Shell to run in `cd` command |\n" + + "| `diff` | `format` | string | `chezmoi` | Diff format, either `chezmoi` or `git` |\n" + + "| | `pager` | string | *none* | Pager |\n" + + "| `genericSecret` | `command` | string | *none* | Generic secret command |\n" + + "| `gopass` | `command` | string | `gopass` | gopass CLI command |\n" + + "| `gpg` | `command` | string | `gpg` | GPG CLI command |\n" + + "| | `recipient` | string | *none* | GPG recipient |\n" + + "| | `symmetric` | bool | `false` | Use symmetric GPG encryption |\n" + + "| `keepassxc` | `args` | []string | *none* | Extra args to KeePassXC CLI command |\n" + + "| | `command` | string | `keepassxc-cli` | KeePassXC CLI command |\n" + + "| | `database` | string | *none* | KeePassXC database |\n" + + "| `lastpass` | `command` | string | `lpass` | Lastpass CLI command |\n" + + "| `merge` | `args` | []string | *none* | Extra args to 3-way merge command |\n" + + "| | `command` | string | `vimdiff` | 3-way merge command |\n" + + "| `onepassword` | `cache` | bool | `true` | Enable optional caching provided by `op` |\n" + + "| | `command` | string | `op` | 1Password CLI command |\n" + + "| `pass` | `command` | string | `pass` | Pass CLI command |\n" + + "| `sourceVCS` | `autoCommit` | bool | `false` | Commit changes to the source state after any change |\n" + + "| | `autoPush` | bool | `false` | Push changes to the source state after any change |\n" + + "| | `command` | string | `git` | Source version control system |\n" + + "| `template` | `options` | []string | `[\"missingkey=error\"]` | Template options |\n" + + "| `vault` | `command` | string | `vault` | Vault CLI command |\n" + + "\n" + + "### Examples\n" + + "\n" + + "#### JSON\n" + + "\n" + + "```json\n" + + "{\n" + + " \"sourceDir\": \"/home/user/.dotfiles\",\n" + + " \"diff\": {\n" + + " \"format\": \"git\"\n" + + " }\n" + + "}\n" + + "```\n" + + "\n" + + "#### TOML\n" + + "\n" + + "```toml\n" + + "sourceDir = \"/home/user/.dotfiles\"\n" + + "[diff]\n" + + " format = \"git\"\n" + + "```\n" + + "\n" + + "#### YAML\n" + + "\n" + + "```yaml\n" + + "sourceDir: /home/user/.dotfiles\n" + + "diff:\n" + + " format: git\n" + + "```\n" + + "\n" + + "## Source state attributes\n" + + "\n" + + "chezmoi stores the source state of files, symbolic links, and directories in\n" + + "regular files and directories in the source directory (`~/.local/share/chezmoi`\n" + + "by default). This location can be overridden with the `-S` flag or by giving a\n" + + "value for `sourceDir` in `~/.config/chezmoi/chezmoi.toml`. Some state is\n" + + "encoded in the source names. chezmoi ignores all files and directories in the\n" + + "source directory that begin with a `.`. The following prefixes and suffixes are\n" + + "special, and are collectively referred to as \"attributes\":\n" + + "\n" + + "| Prefix | Effect |\n" + + "| ------------ | ------------------------------------------------------------------------------ |\n" + + "| `encrypted_` | Encrypt the file in the source state. |\n" + + "| `once_` | Only run script once. |\n" + + "| `private_` | Remove all group and world permissions from the target file or directory. |\n" + + "| `empty_` | Ensure the file exists, even if is empty. By default, empty files are removed. |\n" + + "| `exact_` | Remove anything not managed by chezmoi. |\n" + + "| `executable_`| Add executable permissions to the target file. |\n" + + "| `run_` | Treat the contents as a script to run. |\n" + + "| `symlink_` | Create a symlink instead of a regular file. |\n" + + "| `dot_` | Rename to use a leading dot, e.g. `dot_foo` becomes `.foo`. |\n" + + "\n" + + "| Suffix | Effect |\n" + + "| ------- | ---------------------------------------------------- |\n" + + "| `.tmpl` | Treat the contents of the source file as a template. |\n" + + "\n" + + "Order of prefixes is important, the order is `run_`, `exact_`, `private_`,\n" + + "`empty_`, `executable_`, `symlink_`, `once_`, `dot_`.\n" + + "\n" + + "Different target types allow different prefixes and suffixes:\n" + + "\n" + + "| Target type | Allowed prefixes | Allowed suffixes |\n" + + "| ------------- | --------------------------------------------------------- | ---------------- |\n" + + "| Directory | `exact_`, `private_`, `dot_` | *none* |\n" + + "| Regular file | `encrypted_`, `private_`, `empty_`, `executable_`, `dot_` | `.tmpl` |\n" + + "| Script | `run_`, `once_` | `.tmpl` |\n" + + "| Symbolic link | `symlink_`, `dot_`, | `.tmpl` |\n" + + "\n" + + "## Special files and directories\n" + + "\n" + + "All files and directories in the source state whose name begins with `.` are\n" + + "ignored by default, unless they are one of the special files listed here.\n" + + "\n" + + "### `.chezmoi..tmpl`\n" + + "\n" + + "If a file called `.chezmoi..tmpl` exists then `chezmoi init` will use it\n" + + "to create an initial config file. *format* must be one of the the supported\n" + + "config file formats.\n" + + "\n" + + "#### `.chezmoi..tmpl` examples\n" + + "\n" + + " {{ $email := promptString \"email\" -}}\n" + + " data:\n" + + " email: \"{{ $email }}\"\n" + + "\n" + + "### `.chezmoiignore`\n" + + "\n" + + "If a file called `.chezmoiignore` exists in the source state then it is\n" + + "interpreted as a set of patterns to ignore. Patterns are matched using\n" + + "[`doublestar.PathMatch`](https://pkg.go.dev/github.com/bmatcuk/doublestar?tab=doc#PathMatch)\n" + + "and match against the target path, not the source path.\n" + + "\n" + + "Patterns can be excluded by prefixing them with a `!` character. All excludes\n" + + "take priority over all includes.\n" + + "\n" + + "Comments are introduced with the `#` character and run until the end of the\n" + + "line.\n" + + "\n" + + "`.chezmoiignore` is interpreted as a template. This allows different files to be\n" + + "ignored on different machines.\n" + + "\n" + + "`.chezmoiignore` files in subdirectories apply only to that subdirectory.\n" + + "\n" + + "#### `.chezmoiignore` examples\n" + + "\n" + + " README.md\n" + + "\n" + + " *.txt # ignore *.txt in the target directory\n" + + " */*.txt # ignore *.txt in subdirectories of the target directory\n" + + " backups/** # ignore backups folder in chezmoi directory and all its contents\n" + + "\n" + + " {{- if ne .email \"john.smith@company.com\" }}\n" + + " # Ignore .company-directory unless configured with a company email\n" + + " .company-directory # note that the pattern is not dot_company-directory\n" + + " {{- end }}\n" + + "\n" + + " {{- if ne .email \"john@home.org }}\n" + + " .personal-file\n" + + " {{- end }}\n" + + "\n" + + "### `.chezmoiremove`\n" + + "\n" + + "If a file called `.chezmoiremove` exists in the source state then it is\n" + + "interpreted as a list of targets to remove. `.chezmoiremove` is interpreted as a\n" + + "template.\n" + + "\n" + + "### `.chezmoitemplates`\n" + + "\n" + + "If a directory called `.chezmoitemplates` exists, then all files in this\n" + + "directory are parsed as templates are available as templates with a name equal\n" + + "to the relative path of the file.\n" + + "\n" + + "#### `.chezmoitemplates` examples\n" + + "\n" + + "Given:\n" + + "\n" + + " .chezmoitemplates/foo\n" + + " {{ if true }}bar{{ end }}\n" + + "\n" + + " dot_config.tmpl\n" + + " {{ template \"foo\" }}\n" + + "\n" + + "The target state of `.config` will be `bar`.\n" + + "\n" + + "### `.chezmoiversion`\n" + + "\n" + + "If a file called `.chezmoiversion` exists, then its contents are interpreted as\n" + + "a semantic version defining the minimum version of chezmoi required to interpret\n" + + "the source state correctly. chezmoi will refuse to interpret the source state if\n" + + "the current version is too old.\n" + + "\n" + + "**Warning** support for `.chezmoiversion` will be introduced in a future version\n" + + "(likely 1.5.0). Earlier versions of chezmoi will ignore this file.\n" + + "\n" + + "#### `.chezmoiversion` examples\n" + + "\n" + + " 1.5.0\n" + + "\n" + + "## Commands\n" + + "\n" + + "### `add` *targets*\n" + + "\n" + + "Add *targets* to the source state. If any target is already in the source state,\n" + + "then its source state is replaced with its current state in the destination\n" + + "directory. The `add` command accepts additional flags:\n" + + "\n" + + "#### `--autotemplate`\n" + + "\n" + + "Automatically generate a template by replacing strings with variable names from\n" + + "the `data` section of the config file. Longer substitutions occur before shorter\n" + + "ones. This implies the `--template` option.\n" + + "\n" + + "#### `-e`, `--empty`\n" + + "\n" + + "Set the `empty` attribute on added files.\n" + + "\n" + + "#### `-f`, `--force`\n" + + "\n" + + "Add *targets*, even if doing so would cause a source template to be overwritten.\n" + + "\n" + + "#### `-x`, `--exact`\n" + + "\n" + + "Set the `exact` attribute on added directories.\n" + + "\n" + + "#### `-p`, `--prompt`\n" + + "\n" + + "Interactively prompt before adding each file.\n" + + "\n" + + "#### `-r`, `--recursive`\n" + + "\n" + + "Recursively add all files, directories, and symlinks.\n" + + "\n" + + "#### `-T`, `--template`\n" + + "\n" + + "Set the `template` attribute on added files and symlinks.\n" + + "\n" + + "#### `add` examples\n" + + "\n" + + " chezmoi add ~/.bashrc\n" + + " chezmoi add ~/.gitconfig --template\n" + + " chezmoi add ~/.vim --recursive\n" + + " chezmoi add ~/.oh-my-zsh --exact --recursive\n" + + "\n" + + "### `apply` [*targets*]\n" + + "\n" + + "Ensure that *targets* are in the target state, updating them if necessary. If no\n" + + "targets are specified, the state of all targets are ensured.\n" + + "\n" + + "#### `apply` examples\n" + + "\n" + + " chezmoi apply\n" + + " chezmoi apply --dry-run --verbose\n" + + " chezmoi apply ~/.bashrc\n" + + "\n" + + "### `archive`\n" + + "\n" + + "Generate a tar archive of the target state. This can be piped into `tar` to\n" + + "inspect the target state.\n" + + "\n" + + "#### `--output`, `-o` *filename*\n" + + "\n" + + "Write the output to *filename* instead of stdout.\n" + + "\n" + + "#### `archive` examples\n" + + "\n" + + " chezmoi archive | tar tvf -\n" + + " chezmoi archive --output=dotfiles.tar\n" + + "\n" + + "### `cat` *targets*\n" + + "\n" + + "Write the target state of *targets* to stdout. *targets* must be files or\n" + + "symlinks. For files, the target file contents are written. For symlinks, the\n" + + "target target is written.\n" + + "\n" + + "#### `cat` examples\n" + + "\n" + + " chezmoi cat ~/.bashrc\n" + + "\n" + + "### `cd`\n" + + "\n" + + "Launch a shell in the source directory. chezmoi will launch the command set by\n" + + "the `cd.command` configuration variable with any extra arguments specified by\n" + + "`cd.args`. If this is not set, chezmoi will attempt to detect your shell and\n" + + "will finally fall back to an OS-specific default.\n" + + "\n" + + "#### `cd` examples\n" + + "\n" + + " chezmoi cd\n" + + "\n" + + "### `chattr` *attributes* *targets*\n" + + "\n" + + "Change the attributes of *targets*. *attributes* specifies which attributes to\n" + + "modify. Add attributes by specifying them or their abbreviations directly,\n" + + "optionally prefixed with a plus sign (`+`). Remove attributes by prefixing them\n" + + "or their attributes with the string `no` or a minus sign (`-`). The available\n" + + "attributes and their abbreviations are:\n" + + "\n" + + "| Attribute | Abbreviation |\n" + + "| ------------ | ------------ |\n" + + "| `empty` | `e` |\n" + + "| `encrypted` | *none* |\n" + + "| `exact` | *none* |\n" + + "| `executable` | `x` |\n" + + "| `private` | `p` |\n" + + "| `template` | `t` |\n" + + "\n" + + "Multiple attributes modifications may be specified by separating them with a\n" + + "comma (`,`).\n" + + "\n" + + "#### `chattr` examples\n" + + "\n" + + " chezmoi chattr template ~/.bashrc\n" + + " chezmoi chattr noempty ~/.profile\n" + + " chezmoi chattr private,template ~/.netrc\n" + + "\n" + + "### `completion` *shell*\n" + + "\n" + + "Generate shell completion code for the specified shell (`bash`, `fish`,\n" + + "`powershell`, or `zsh`).\n" + + "\n" + + "#### `--output`, `-o` *filename*\n" + + "\n" + + "Write the shell completion code to *filename* instead of stdout.\n" + + "\n" + + "#### `completion` examples\n" + + "\n" + + " chezmoi completion bash\n" + + " chezmoi completion fish --output ~/.config/fish/completions/chezmoi.fish\n" + + "\n" + + "### `data`\n" + + "\n" + + "Write the computed template data in JSON format to stdout. The `data` command\n" + + "accepts additional flags:\n" + + "\n" + + "#### `-f`, `--format` *format*\n" + + "\n" + + "Print the computed template data in the given format. The accepted formats are\n" + + "`json` (JSON), `toml` (TOML), and `yaml` (YAML).\n" + + "\n" + + "#### `data` examples\n" + + "\n" + + " chezmoi data\n" + + " chezmoi data --format=yaml\n" + + "\n" + + "### `diff` [*targets*]\n" + + "\n" + + "Print the difference between the target state and the destination state for\n" + + "*targets*. If no targets are specified, print the differences for all targets.\n" + + "\n" + + "If a `diff.pager` command is set in the configuration file then the output will\n" + + "be piped into it.\n" + + "\n" + + "#### `-f`, `--format` *format*\n" + + "\n" + + "Print the diff in *format*. The format can be set with the `diff.format`\n" + + "variable in the configuration file. Valid formats are:\n" + + "\n" + + "##### `chezmoi`\n" + + "\n" + + "A mix of unified diffs and pseudo shell commands, including scripts, equivalent\n" + + "to `chezmoi apply --dry-run --verbose`.\n" + + "\n" + + "##### `git`\n" + + "\n" + + "A [git format diff](https://git-scm.com/docs/diff-format), excluding scripts. In\n" + + "version 2.0.0 of chezmoi, `git` format diffs will become the default and include\n" + + "scripts and the `chezmoi` format will be removed.\n" + + "\n" + + "#### `--no-pager`\n" + + "\n" + + "Do not use the pager.\n" + + "\n" + + "#### `diff` examples\n" + + "\n" + + " chezmoi diff\n" + + " chezmoi diff ~/.bashrc\n" + + " chezmoi diff --format=git\n" + + "\n" + + "### `docs` [*regexp*]\n" + + "\n" + + "Print the documentation page matching the regular expression *regexp*. Matching\n" + + "is case insensitive. If no pattern is given, print `REFERENCE.md`.\n" + + "\n" + + "#### `docs` examples\n" + + "\n" + + " chezmoi docs\n" + + " chezmoi docs faq\n" + + " chezmoi docs howto\n" + + "\n" + + "### `doctor`\n" + + "\n" + + "Check for potential problems.\n" + + "\n" + + "#### `doctor` examples\n" + + "\n" + + " chezmoi doctor\n" + + "\n" + + "### `dump` [*targets*]\n" + + "\n" + + "Dump the target state in JSON format. If no targets are specified, then the\n" + + "entire target state. The `dump` command accepts additional arguments:\n" + + "\n" + + "#### `-f`, `--format` *format*\n" + + "\n" + + "Print the target state in the given format. The accepted formats are `json`\n" + + "(JSON) and `yaml` (YAML).\n" + + "\n" + + "#### `dump` examples\n" + + "\n" + + " chezmoi dump ~/.bashrc\n" + + " chezmoi dump --format=yaml\n" + + "\n" + + "### `edit` [*targets*]\n" + + "\n" + + "Edit the source state of *targets*, which must be files or symlinks. If no\n" + + "targets are given the the source directory itself is opened with `$EDITOR`. The\n" + + "`edit` command accepts additional arguments:\n" + + "\n" + + "#### `-a`, `--apply`\n" + + "\n" + + "Apply target immediately after editing. Ignored if there are no targets.\n" + + "\n" + + "#### `-d`, `--diff`\n" + + "\n" + + "Print the difference between the target state and the actual state after\n" + + "editing.. Ignored if there are no targets.\n" + + "\n" + + "#### `-p`, `--prompt`\n" + + "\n" + + "Prompt before applying each target.. Ignored if there are no targets.\n" + + "\n" + + "#### `edit` examples\n" + + "\n" + + " chezmoi edit ~/.bashrc\n" + + " chezmoi edit ~/.bashrc --apply --prompt\n" + + " chezmoi edit\n" + + "\n" + + "### `edit-config`\n" + + "\n" + + "Edit the configuration file.\n" + + "\n" + + "#### `edit-config` examples\n" + + "\n" + + " chezmoi edit-config\n" + + "\n" + + "### `execute-template` [*templates*]\n" + + "\n" + + "Execute *templates*. This is useful for testing templates or for calling chezmoi\n" + + "from other scripts. *templates* are interpreted as literal templates, with no\n" + + "whitespace added to the output between arguments. If no templates are specified,\n" + + "the template is read from stdin.\n" + + "\n" + + "#### `--init`, `-i`\n" + + "\n" + + "Include simulated functions only available during `chezmoi init`.\n" + + "\n" + + "#### `--output`, `-o` *filename*\n" + + "\n" + + "Write the output to *filename* instead of stdout.\n" + + "\n" + + "#### `--promptBool` *pairs*\n" + + "\n" + + "Simulate the `promptBool` function with a function that returns values from\n" + + "*pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If\n" + + "`promptBool` is called with a *prompt* that does not match any of *pairs*, then\n" + + "it returns false.\n" + + "\n" + + "#### `--promptInt`, `-p` *pairs*\n" + + "\n" + + "Simulate the `promptInt` function with a function that returns values from\n" + + "*pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If\n" + + "`promptInt` is called with a *prompt* that does not match any of *pairs*, then\n" + + "it returns zero.\n" + + "\n" + + "#### `--promptString`, `-p` *pairs*\n" + + "\n" + + "Simulate the `promptString` function with a function that returns values from\n" + + "*pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If\n" + + "`promptString` is called with a *prompt* that does not match any of *pairs*,\n" + + "then it returns *prompt* unchanged.\n" + + "\n" + + "#### `execute-template` examples\n" + + "\n" + + " chezmoi execute-template '{{ .chezmoi.sourceDir }}'\n" + + " chezmoi execute-template '{{ .chezmoi.os }}' / '{{ .chezmoi.arch }}'\n" + + " echo '{{ .chezmoi | toJson }}' | chezmoi execute-template\n" + + " chezmoi execute-template --init --promptString email=john@home.org < ~/.local/share/chezmoi/.chezmoi.toml.tmpl\n" + + "\n" + + "### `forget` *targets*\n" + + "\n" + + "Remove *targets* from the source state, i.e. stop managing them.\n" + + "\n" + + "#### `forget` examples\n" + + "\n" + + " chezmoi forget ~/.bashrc\n" + + "\n" + + "### `git` [*arguments*]\n" + + "\n" + + "Run `git` *arguments* in the source directory. Note that flags in *arguments*\n" + + "must occur after `--` to prevent chezmoi from interpreting them.\n" + + "\n" + + "#### `git` examples\n" + + "\n" + + " chezmoi git add .\n" + + " chezmoi git add dot_gitconfig\n" + + " chezmoi git -- commit -m \"Add .gitconfig\"\n" + + "\n" + + "### `help` *command*\n" + + "\n" + + "Print the help associated with *command*.\n" + + "\n" + + "### `hg` [*arguments*]\n" + + "\n" + + "Run `hg` *arguments* in the source directory. Note that flags in *arguments*\n" + + "must occur after `--` to prevent chezmoi from interpreting them.\n" + + "\n" + + "#### `hg` examples\n" + + "\n" + + " chezmoi hg -- pull --rebase --update\n" + + "\n" + + "### `init` [*repo*]\n" + + "\n" + + "Setup the source directory and update the destination directory to match the\n" + + "target state.\n" + + "\n" + + "First, if the source directory is not already contain a repository, then if\n" + + "*repo* is given it is checked out into the source directory, otherwise a new\n" + + "repository is initialized in the source directory.\n" + + "\n" + + "Second, if a file called `.chezmoi.format.tmpl` exists, where `format` is one of\n" + + "the supported file formats (e.g. `json`, `toml`, or `yaml`) then a new\n" + + "configuration file is created using that file as a template.\n" + + "\n" + + "Finally, if the `--apply` flag is passed, `chezmoi apply` is run.\n" + + "\n" + + "#### `--apply`\n" + + "\n" + + "Run `chezmoi apply` after checking out the repo and creating the config file.\n" + + "This is `false` by default.\n" + + "\n" + + "#### `init` examples\n" + + "\n" + + " chezmoi init https://github.com/user/dotfiles.git\n" + + " chezmoi init https://github.com/user/dotfiles.git --apply\n" + + "\n" + + "### `import` *filename*\n" + + "\n" + + "Import the source state from an archive file in to a directory in the source\n" + + "state. This is primarily used to make subdirectories of your home directory\n" + + "exactly match the contents of a downloaded archive. You will generally always\n" + + "want to set the `--destination`, `--exact`, and `--remove-destination` flags.\n" + + "\n" + + "The only supported archive format is `.tar.gz`.\n" + + "\n" + + "#### `--destination` *directory*\n" + + "\n" + + "Set the destination (in the source state) where the archive will be imported.\n" + + "\n" + + "#### `-x`, `--exact`\n" + + "\n" + + "Set the `exact` attribute on all imported directories.\n" + + "\n" + + "#### `-r`, `--remove-destination`\n" + + "\n" + + "Remove destination (in the source state) before importing.\n" + + "\n" + + "#### `--strip-components` *n*\n" + + "\n" + + "Strip *n* leading components from paths.\n" + + "\n" + + "#### `import` examples\n" + + "\n" + + " curl -s -L -o oh-my-zsh-master.tar.gz https://github.com/robbyrussell/oh-my-zsh/archive/master.tar.gz\n" + + " chezmoi import --strip-components 1 --destination ~/.oh-my-zsh oh-my-zsh-master.tar.gz\n" + + "\n" + + "### `manage` *targets*\n" + + "\n" + + "`manage` is an alias for `add` for symmetry with `unmanage`.\n" + + "\n" + + "### `managed`\n" + + "\n" + + "List all managed entries in the destination directory in alphabetical order.\n" + + "\n" + + "#### `-i`, `--include` *types*\n" + + "\n" + + "Only list entries of type *types*. *types* is a comma-separated list of types of\n" + + "entry to include. Valid types are `dirs`, `files`, and `symlinks` which can be\n" + + "abbreviated to `d`, `f`, and `s` respectively. By default, `manage` will list\n" + + "entries of all types.\n" + + "\n" + + "#### `managed` examples\n" + + "\n" + + " chezmoi managed\n" + + " chezmoi managed --include=files\n" + + " chezmoi managed --include=files,symlinks\n" + + " chezmoi managed -i d\n" + + " chezmoi managed -i d,f\n" + + "\n" + + "### `merge` *targets*\n" + + "\n" + + "Perform a three-way merge between the destination state, the source state, and\n" + + "the target state. The merge tool is defined by the `merge.command` configuration\n" + + "variable, and defaults to `vimdiff`. If multiple targets are specified the merge\n" + + "tool is invoked for each target. If the target state cannot be computed (for\n" + + "example if source is a template containing errors or an encrypted file that\n" + + "cannot be decrypted) a two-way merge is performed instead.\n" + + "\n" + + "#### `merge` examples\n" + + "\n" + + " chezmoi merge ~/.bashrc\n" + + "\n" + + "### `purge`\n" + + "\n" + + "Remove chezmoi's configuration, state, and source directory, but leave the\n" + + "target state intact.\n" + + "\n" + + "#### `-f`, `--force`\n" + + "\n" + + "Remove without prompting.\n" + + "\n" + + "#### `purge` examples\n" + + "\n" + + " chezmoi purge\n" + + " chezmoi purge --force\n" + + "\n" + + "### `remove` *targets*\n" + + "\n" + + "Remove *targets* from both the source state and the destination directory.\n" + + "\n" + + "#### `-f`, `--force`\n" + + "\n" + + "Remove without prompting.\n" + + "\n" + + "### `rm` *targets*\n" + + "\n" + + "`rm` is an alias for `remove`.\n" + + "\n" + + "### `secret`\n" + + "\n" + + "Run a secret manager's CLI, passing any extra arguments to the secret manager's\n" + + "CLI. This is primarily for verifying chezmoi's integration with your secret\n" + + "manager. Normally you would use template functions to retrieve secrets. Note\n" + + "that if you want to pass flags to the secret manager's CLI you will need to\n" + + "separate them with `--` to prevent chezmoi from interpreting them.\n" + + "\n" + + "To get a full list of available commands run:\n" + + "\n" + + " chezmoi secret help\n" + + "\n" + + "#### `secret` examples\n" + + "\n" + + " chezmoi secret bitwarden list items\n" + + " chezmoi secret keyring set --service service --user user\n" + + " chezmoi secret keyring get --service service --user user\n" + + " chezmoi secret lastpass ls\n" + + " chezmoi secret lastpass -- show --format=json id\n" + + " chezmoi secret onepassword list items\n" + + " chezmoi secret onepassword get item id\n" + + " chezmoi secret pass show id\n" + + " chezmoi secret vault -- kv get -format=json id\n" + + "\n" + + "### `source` [*args*]\n" + + "\n" + + "Execute the source version control system in the source directory with *args*.\n" + + "Note that any flags for the source version control system must be separated with\n" + + "a `--` to stop chezmoi from reading them.\n" + + "\n" + + "#### `source` examples\n" + + "\n" + + " chezmoi source init\n" + + " chezmoi source add .\n" + + " chezmoi source commit -- -m \"Initial commit\"\n" + + "\n" + + "### `source-path` [*targets*]\n" + + "\n" + + "Print the path to each target's source state. If no targets are specified then\n" + + "print the source directory.\n" + + "\n" + + "#### `source-path` examples\n" + + "\n" + + " chezmoi source-path\n" + + " chezmoi source-path ~/.bashrc\n" + + "\n" + + "### `unmanage` *targets*\n" + + "\n" + + "`unmanage` is an alias for `forget` for symmetry with `manage`.\n" + + "\n" + + "### `unmanaged`\n" + + "\n" + + "List all unmanaged files in the destination directory.\n" + + "\n" + + "#### `unmanaged` examples\n" + + "\n" + + " chezmoi unmanaged\n" + + "\n" + + "### `update`\n" + + "\n" + + "Pull changes from the source VCS and apply any changes.\n" + + "\n" + + "#### `update` examples\n" + + "\n" + + " chezmoi update\n" + + "\n" + + "### `upgrade`\n" + + "\n" + + "Upgrade chezmoi by downloading and installing the latest released version. This\n" + + "will call the GitHub API to determine if there is a new version of chezmoi\n" + + "available, and if so, download and attempt to install it in the same way as\n" + + "chezmoi was previously installed.\n" + + "\n" + + "If chezmoi was installed with a package manager (`dpkg` or `rpm`) then `upgrade`\n" + + "will download a new package and install it, using `sudo` if it is installed.\n" + + "Otherwise, chezmoi will download the latest executable and replace the existing\n" + + "executable with the new version.\n" + + "\n" + + "If the `CHEZMOI_GITHUB_API_TOKEN` environment variable is set, then its value\n" + + "will be used to authenticate requests to the GitHub API, otherwise\n" + + "unauthenticated requests are used which are subject to stricter [rate\n" + + "limiting](https://developer.github.com/v3/#rate-limiting). Unauthenticated\n" + + "requests should be sufficient for most cases.\n" + + "\n" + + "#### `upgrade` examples\n" + + "\n" + + " chezmoi upgrade\n" + + "\n" + + "### `verify` [*targets*]\n" + + "\n" + + "Verify that all *targets* match their target state. chezmoi exits with code 0\n" + + "(success) if all targets match their target state, or 1 (failure) otherwise. If\n" + + "no targets are specified then all targets are checked.\n" + + "\n" + + "#### `verify` examples\n" + + "\n" + + " chezmoi verify\n" + + " chezmoi verify ~/.bashrc\n" + + "\n" + + "## Editor configuration\n" + + "\n" + + "The `edit` and `edit-config` commands use the editor specified by the `VISUAL`\n" + + "environment variable, the `EDITOR` environment variable, or `vi`, whichever is\n" + + "specified first.\n" + + "\n" + + "## Umask configuration\n" + + "\n" + + "By default, chezmoi uses your current umask as set by your operating system and\n" + + "shell. chezmoi only stores crude permissions in its source state, namely in the\n" + + "`executable` and `private` attributes, corresponding to the umasks of `0o111`\n" + + "and `0o077` respectively.\n" + + "\n" + + "For machine-specific control of umask, set the `umask` configuration variable in\n" + + "chezmoi's configuration file, for example:\n" + + "\n" + + " umask = 0o22\n" + + "\n" + + "## Template execution\n" + + "\n" + + "chezmoi executes templates using\n" + + "[`text/template`](https://pkg.go.dev/text/template). The result is treated\n" + + "differently depending on whether the target is a file or a symlink.\n" + + "\n" + + "If target is a file, then:\n" + + "\n" + + "* If the result is an empty string, then the file is removed.\n" + + "* Otherwise, the target file contents are result.\n" + + "\n" + + "If the target is a symlink, then:\n" + + "\n" + + "* Leading and trailing whitespace are stripped from the result.\n" + + "* If the result is an empty string, then the symlink is removed.\n" + + "* Otherwise, the target symlink target is the result.\n" + + "\n" + + "chezmoi executes templates using `text/template`'s `missingkey=error` option,\n" + + "which means that misspelled or missing keys will raise an error. This can be\n" + + "overridden by setting a list of options in the configuration file, for example:\n" + + "\n" + + " [template]\n" + + " options = [\"missingkey=zero\"]\n" + + "\n" + + "For a full list of options, see\n" + + "[`Template.Option`](https://pkg.go.dev/text/template?tab=doc#Template.Option).\n" + + "\n" + + "## Template variables\n" + + "\n" + + "chezmoi provides the following automatically populated variables:\n" + + "\n" + + "| Variable | Value |\n" + + "| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |\n" + + "| `.chezmoi.arch` | Architecture, e.g. `amd64`, `arm`, etc. as returned by [runtime.GOARCH](https://pkg.go.dev/runtime?tab=doc#pkg-constants). |\n" + + "| `.chezmoi.fullHostname` | The full hostname of the machine chezmoi is running on. |\n" + + "| `.chezmoi.group` | The group of the user running chezmoi. |\n" + + "| `.chezmoi.homedir` | The home directory of the user running chezmoi. |\n" + + "| `.chezmoi.hostname` | The hostname of the machine chezmoi is running on, up to the first `.`. |\n" + + "| `.chezmoi.kernel` | Contains information from `/proc/sys/kernel`. Linux only, useful for detecting specific kernels (i.e. Microsoft's WSL kernel). |\n" + + "| `.chezmoi.os` | Operating system, e.g. `darwin`, `linux`, etc. as returned by [runtime.GOOS](https://pkg.go.dev/runtime?tab=doc#pkg-constants). |\n" + + "| `.chezmoi.osRelease` | The information from `/etc/os-release`, Linux only, run `chezmoi data` to see its output. |\n" + + "| `.chezmoi.sourceDir` | The source directory. |\n" + + "| `.chezmoi.username` | The username of the user running chezmoi. |\n" + + "\n" + + "Additional variables can be defined in the config file in the `data` section.\n" + + "Variable names must consist of a letter and be followed by zero or more letters\n" + + "and/or digits.\n" + + "\n" + + "## Template functions\n" + + "\n" + + "All standard [`text/template`](https://pkg.go.dev/text/template) and [text\n" + + "template functions from `sprig`](http://masterminds.github.io/sprig/) are\n" + + "included. chezmoi provides some additional functions.\n" + + "\n" + + "### `bitwarden` [*args*]\n" + + "\n" + + "`bitwarden` returns structured data retrieved from\n" + + "[Bitwarden](https://bitwarden.com) using the [Bitwarden\n" + + "CLI](https://github.com/bitwarden/cli) (`bw`). *args* are passed to `bw`\n" + + "unchanged and the output from `bw` is parsed as JSON. The output from `bw` is\n" + + "cached so calling `bitwarden` multiple times with the same arguments will only\n" + + "invoke `bw` once.\n" + + "\n" + + "#### `bitwarden` examples\n" + + "\n" + + " username = {{ (bitwarden \"item\" \"example.com\").login.username }}\n" + + " password = {{ (bitwarden \"item\" \"example.com\").login.password }}\n" + + "\n" + + "### `bitwardenFields` [*args*]\n" + + "\n" + + "`bitwardenFields` returns structured data retrieved from\n" + + "[Bitwarden](https://bitwarden.com) using the [Bitwarden\n" + + "CLI](https://github.com/bitwarden/cli) (`bw`). *args* are passed to `bw`\n" + + "unchanged and the output from `bw` is parsed as JSON, and\n" + + "elements of `fields` are returned as a map indexed by each field's\n" + + "`name`. For example, given the output from `bw`:\n" + + "\n" + + "```json\n" + + "{\n" + + " \"object\": \"item\",\n" + + " \"id\": \"bf22e4b4-ae4a-4d1c-8c98-ac620004b628\",\n" + + " \"organizationId\": null,\n" + + " \"folderId\": null,\n" + + " \"type\": 1,\n" + + " \"name\": \"example.com\",\n" + + " \"notes\": null,\n" + + " \"favorite\": false,\n" + + " \"fields\": [\n" + + " {\n" + + " \"name\": \"text\",\n" + + " \"value\": \"text-value\",\n" + + " \"type\": 0\n" + + " },\n" + + " {\n" + + " \"name\": \"hidden\",\n" + + " \"value\": \"hidden-value\",\n" + + " \"type\": 1\n" + + " }\n" + + " ],\n" + + " \"login\": {\n" + + " \"username\": \"username-value\",\n" + + " \"password\": \"password-value\",\n" + + " \"totp\": null,\n" + + " \"passwordRevisionDate\": null\n" + + " },\n" + + " \"collectionIds\": [],\n" + + " \"revisionDate\": \"2020-10-28T00:21:02.690Z\"\n" + + "}\n" + + "```\n" + + "\n" + + "the return value will be the map\n" + + "\n" + + "```json\n" + + "{\n" + + " \"hidden\": {\n" + + " \"name\": \"hidden\",\n" + + " \"type\": 1,\n" + + " \"value\": \"hidden-value\"\n" + + " },\n" + + " \"token\": {\n" + + " \"name\": \"token\",\n" + + " \"type\": 0,\n" + + " \"value\": \"token-value\"\n" + + " }\n" + + "}\n" + + "```\n" + + "\n" + + "The output from `bw` is cached so calling `bitwarden` multiple times with the\n" + + "same arguments will only invoke `bw` once.\n" + + "\n" + + "#### `bitwardenFields` examples\n" + + "\n" + + " {{ (bitwardenFields \"item\" \"example.com\").token.value }}\n" + + "\n" + + "### `gopass` *gopass-name*\n" + + "\n" + + "`gopass` returns passwords stored in [gopass](https://www.gopass.pw/) using the\n" + + "gopass CLI (`gopass`). *gopass-name* is passed to `gopass show `\n" + + "and first line of the output of `gopass` is returned with the trailing newline\n" + + "stripped. The output from `gopass` is cached so calling `gopass` multiple times\n" + + "with the same *gopass-name* will only invoke `gopass` once.\n" + + "\n" + + "#### `gopass` examples\n" + + "\n" + + " {{ gopass \"\" }}\n" + + "\n" + + "### `include` *filename*\n" + + "\n" + + "`include` returns the literal contents of the file named `*filename*`, relative\n" + + "to the source directory.\n" + + "\n" + + "### `ioreg`\n" + + "\n" + + "On macOS, `ioreg` returns the structured output of the `ioreg -a -l` command,\n" + + "which includes detailed information about the I/O Kit registry.\n" + + "\n" + + "On non-macOS operating systems, `ioreg` returns `nil`.\n" + + "\n" + + "The output from `ioreg` is cached so multiple calls to the `ioreg` function will\n" + + "only execute the `ioreg -a -l` command once.\n" + + "\n" + + "#### `ioreg` examples\n" + + "\n" + + " {{ if (eq .chezmoi.os \"darwin\") }}\n" + + " {{ $serialNumber := index ioreg \"IORegistryEntryChildren\" 0 \"IOPlatformSerialNumber\" }}\n" + + " {{ end }}\n" + + "\n" + + "### `joinPath` *elements*\n" + + "\n" + + "`joinPath` joins any number of path elements into a single path, separating them\n" + + "with the OS-specific path separator. Empty elements are ignored. The result is\n" + + "cleaned. If the argument list is empty or all its elements are empty, `joinPath`\n" + + "returns an empty string. On Windows, the result will only be a UNC path if the\n" + + "first non-empty element is a UNC path.\n" + + "\n" + + "#### `joinPath` examples\n" + + "\n" + + " {{ joinPath .chezmoi.homedir \".zshrc\" }}\n" + + "\n" + + "### `keepassxc` *entry*\n" + + "\n" + + "`keepassxc` returns structured data retrieved from a\n" + + "[KeePassXC](https://keepassxc.org/) database using the KeePassXC CLI\n" + + "(`keepassxc-cli`). The database is configured by setting `keepassxc.database` in\n" + + "the configuration file. *database* and *entry* are passed to `keepassxc-cli\n" + + "show`. You will be prompted for the database password the first time\n" + + "`keepassxc-cli` is run, and the password is cached, in plain text, in memory\n" + + "until chezmoi terminates. The output from `keepassxc-cli` is parsed into\n" + + "key-value pairs and cached so calling `keepassxc` multiple times with the same\n" + + "*entry* will only invoke `keepassxc-cli` once.\n" + + "\n" + + "#### `keepassxc` examples\n" + + "\n" + + " username = {{ (keepassxc \"example.com\").UserName }}\n" + + " password = {{ (keepassxc \"example.com\").Password }}\n" + + "\n" + + "### `keepassxcAttribute` *entry* *attribute*\n" + + "\n" + + "`keepassxcAttribute` returns the attribute *attribute* of *entry* using\n" + + "`keepassxc-cli`, with any leading or trailing whitespace removed. It behaves\n" + + "identically to the `keepassxc` function in terms of configuration, password\n" + + "prompting, password storage, and result caching.\n" + + "\n" + + "#### `keepassxcAttribute` examples\n" + + "\n" + + " {{ keepassxcAttribute \"SSH Key\" \"private-key\" }}\n" + + "\n" + + "### `keyring` *service* *user*\n" + + "\n" + + "`keyring` retrieves the value associated with *service* and *user* from the\n" + + "user's keyring.\n" + + "\n" + + "| OS | Keyring |\n" + + "| ------- | --------------------------- |\n" + + "| macOS | Keychain |\n" + + "| Linux | GNOME Keyring |\n" + + "| Windows | Windows Credentials Manager |\n" + + "\n" + + "#### `keyring` examples\n" + + "\n" + + " [github]\n" + + " user = \"{{ .github.user }}\"\n" + + " token = \"{{ keyring \"github\" .github.user }}\"\n" + + "\n" + + "### `lastpass` *id*\n" + + "\n" + + "`lastpass` returns structured data from [LastPass](https://lastpass.com) using\n" + + "the [LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html)\n" + + "(`lpass`). *id* is passed to `lpass show --json ` and the output from\n" + + "`lpass` is parsed as JSON. In addition, the `note` field, if present, is further\n" + + "parsed as colon-separated key-value pairs. The structured data is an array so\n" + + "typically the `index` function is used to extract the first item. The output\n" + + "from `lastpass` is cached so calling `lastpass` multiple times with the same\n" + + "*id* will only invoke `lpass` once.\n" + + "\n" + + "#### `lastpass` examples\n" + + "\n" + + " githubPassword = \"{{ (index (lastpass \"GitHub\") 0).password }}\"\n" + + " {{ (index (lastpass \"SSH\") 0).note.privateKey }}\n" + + "\n" + + "### `lastpassRaw` *id*\n" + + "\n" + + "`lastpassRaw` returns structured data from [LastPass](https://lastpass.com)\n" + + "using the [LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html)\n" + + "(`lpass`). It behaves identically to the `lastpass` function, except that no\n" + + "further parsing is done on the `note` field.\n" + + "\n" + + "#### `lastpassRaw` examples\n" + + "\n" + + " {{ (index (lastpassRaw \"SSH Private Key\") 0).note }}\n" + + "\n" + + "### `lookPath` *file*\n" + + "\n" + + "`lookPath` searches for an executable named *file* in the directories named by\n" + + "the `PATH` environment variable. If file contains a slash, it is tried directly\n" + + "and the `PATH` is not consulted. The result may be an absolute path or a path\n" + + "relative to the current directory. If *file* is not found, `lookPath` returns an\n" + + "empty string.\n" + + "\n" + + "`lookPath` is not hermetic: its return value depends on the state of the\n" + + "environment and the filesystem at the moment the template is executed. Exercise\n" + + "caution when using it in your templates.\n" + + "\n" + + "#### `lookPath` examples\n" + + "\n" + + " {{ if lookPath \"diff-so-fancy\" }}\n" + + " # diff-so-fancy is in $PATH\n" + + " {{ end }}\n" + + "\n" + + "### `onepassword` *uuid* [*vault-uuid*]\n" + + "\n" + + "`onepassword` returns structured data from [1Password](https://1password.com/)\n" + + "using the [1Password\n" + + "CLI](https://support.1password.com/command-line-getting-started/) (`op`). *uuid*\n" + + "is passed to `op get item ` and the output from `op` is parsed as JSON.\n" + + "The output from `op` is cached so calling `onepassword` multiple times with the\n" + + "same *uuid* will only invoke `op` once. If the optional *vault-uuid* is supplied,\n" + + "it will be passed along to the `op get` call, which can significantly improve\n" + + "performance.\n" + + "\n" + + "#### `onepassword` examples\n" + + "\n" + + " {{ (onepassword \"\").details.password }}\n" + + " {{ (onepassword \"\" \"\").details.password }}\n" + + "\n" + + "### `onepasswordDocument` *uuid* [*vault-uuid*]\n" + + "\n" + + "`onepassword` returns a document from [1Password](https://1password.com/)\n" + + "using the [1Password\n" + + "CLI](https://support.1password.com/command-line-getting-started/) (`op`). *uuid*\n" + + "is passed to `op get document ` and the output from `op` is returned.\n" + + "The output from `op` is cached so calling `onepasswordDocument` multiple times with the\n" + + "same *uuid* will only invoke `op` once. If the optional *vault-uuid* is supplied,\n" + + "it will be passed along to the `op get` call, which can significantly improve\n" + + "performance.\n" + + "\n" + + "#### `onepasswordDocument` examples\n" + + "\n" + + " {{- onepasswordDocument \"\" -}}\n" + + " {{- onepasswordDocument \"\" \"\" -}}\n" + + "\n" + + "### `onepasswordDetailsFields` *uuid* [*vault-uuid*]\n" + + "\n" + + "`onepasswordDetailsFields` returns structured data from\n" + + "[1Password](https://1password.com/) using the [1Password\n" + + "CLI](https://support.1password.com/command-line-getting-started/) (`op`). *uuid*\n" + + "is passed to `op get item `, the output from `op` is parsed as JSON, and\n" + + "elements of `details.fields` are returned as a map indexed by each field's\n" + + "`designation`. For example, give the output from `op`:\n" + + "\n" + + "```json\n" + + "{\n" + + " \"uuid\": \"\",\n" + + " \"details\": {\n" + + " \"fields\": [\n" + + " {\n" + + " \"designation\": \"username\",\n" + + " \"name\": \"username\",\n" + + " \"type\": \"T\",\n" + + " \"value\": \"exampleuser\"\n" + + " },\n" + + " {\n" + + " \"designation\": \"password\",\n" + + " \"name\": \"password\",\n" + + " \"type\": \"P\",\n" + + " \"value\": \"examplepassword\"\n" + + " }\n" + + " ],\n" + + " }\n" + + "}\n" + + "```\n" + + "\n" + + "the return value will be the map:\n" + + "\n" + + "```json\n" + + "{\n" + + " \"username\": {\n" + + " \"designation\": \"username\",\n" + + " \"name\": \"username\",\n" + + " \"type\": \"T\",\n" + + " \"value\": \"exampleuser\"\n" + + " },\n" + + " \"password\": {\n" + + " \"designation\": \"password\",\n" + + " \"name\": \"password\",\n" + + " \"type\": \"P\",\n" + + " \"value\": \"examplepassword\"\n" + + " }\n" + + "}\n" + + "```\n" + + "\n" + + "The output from `op` is cached so calling `onepassword` multiple times with the\n" + + "same *uuid* will only invoke `op` once. If the optional *vault-uuid* is supplied,\n" + + "it will be passed along to the `op get` call, which can significantly improve\n" + + "performance.\n" + + "\n" + + "#### `onepasswordDetailsFields` examples\n" + + "\n" + + " {{ (onepasswordDetailsFields \"\").password.value }}\n" + + "\n" + + "### `pass` *pass-name*\n" + + "\n" + + "`pass` returns passwords stored in [pass](https://www.passwordstore.org/) using\n" + + "the pass CLI (`pass`). *pass-name* is passed to `pass show ` and\n" + + "first line of the output of `pass` is returned with the trailing newline\n" + + "stripped. The output from `pass` is cached so calling `pass` multiple times with\n" + + "the same *pass-name* will only invoke `pass` once.\n" + + "\n" + + "#### `pass` examples\n" + + "\n" + + " {{ pass \"\" }}\n" + + "\n" + + "### `promptBool` *prompt*\n" + + "\n" + + "`promptBool` prompts the user with *prompt* and returns the user's response with\n" + + "interpreted as a boolean. It is only available when generating the initial\n" + + "config file.\n" + + "\n" + + "### `promptInt` *prompt*\n" + + "\n" + + "`promptInt` prompts the user with *prompt* and returns the user's response with\n" + + "interpreted as an integer. It is only available when generating the initial\n" + + "config file.\n" + + "\n" + + "### `promptString` *prompt*\n" + + "\n" + + "`promptString` prompts the user with *prompt* and returns the user's response\n" + + "with all leading and trailing spaces stripped. It is only available when\n" + + "generating the initial config file.\n" + + "\n" + + "#### `promptString` examples\n" + + "\n" + + " {{ $email := promptString \"email\" -}}\n" + + " [data]\n" + + " email = \"{{ $email }}\"\n" + + "\n" + + "### `secret` [*args*]\n" + + "\n" + + "`secret` returns the output of the generic secret command defined by the\n" + + "`genericSecret.command` configuration variable with *args* with leading and\n" + + "trailing whitespace removed. The output is cached so multiple calls to `secret`\n" + + "with the same *args* will only invoke the generic secret command once.\n" + + "\n" + + "### `secretJSON` [*args*]\n" + + "\n" + + "`secretJSON` returns structured data from the generic secret command defined by\n" + + "the `genericSecret.command` configuration variable with *args*. The output is\n" + + "parsed as JSON. The output is cached so multiple calls to `secret` with the same\n" + + "*args* will only invoke the generic secret command once.\n" + + "\n" + + "### `stat` *name*\n" + + "\n" + + "`stat` runs `stat(2)` on *name*. If *name* exists it returns structured data. If\n" + + "*name* does not exist then it returns a false value. If `stat(2)` returns any\n" + + "other error then it raises an error. The structured value returned if *name*\n" + + "exists contains the fields `name`, `size`, `mode`, `perm`, `modTime`, and\n" + + "`isDir`.\n" + + "\n" + + "`stat` is not hermetic: its return value depends on the state of the filesystem\n" + + "at the moment the template is executed. Exercise caution when using it in your\n" + + "templates.\n" + + "\n" + + "#### `stat` examples\n" + + "\n" + + " {{ if stat (joinPath .chezmoi.homedir \".pyenv\") }}\n" + + " # ~/.pyenv exists\n" + + " {{ end }}\n" + + "\n" + + "### `vault` *key*\n" + + "\n" + + "`vault` returns structured data from [Vault](https://www.vaultproject.io/) using\n" + + "the [Vault CLI](https://www.vaultproject.io/docs/commands/) (`vault`). *key* is\n" + + "passed to `vault kv get -format=json ` and the output from `vault` is\n" + + "parsed as JSON. The output from `vault` is cached so calling `vault` multiple\n" + + "times with the same *key* will only invoke `vault` once.\n" + + "\n" + + "#### `vault` examples\n" + + "\n" + + " {{ (vault \"\").data.data.password }}\n" + + "\n") +} diff --git a/chezmoi2/cmd/docs_embeddocs.go b/chezmoi2/cmd/docs_embeddocs.go new file mode 100644 index 000000000000..b812bc048eb3 --- /dev/null +++ b/chezmoi2/cmd/docs_embeddocs.go @@ -0,0 +1,27 @@ +// +build !nodocs +// +build !noembeddocs + +package cmd + +import ( + "strings" +) + +// DocsDir is unused when chezmoi is built with embedded docs. +var DocsDir = "" + +var docsPrefix = "docs/" + +func doc(filename string) ([]byte, error) { + return asset(docsPrefix + filename) +} + +func docsFilenames() ([]string, error) { + var docsFilenames []string + for name := range assets { + if strings.HasPrefix(name, docsPrefix) { + docsFilenames = append(docsFilenames, strings.TrimPrefix(name, docsPrefix)) + } + } + return docsFilenames, nil +} diff --git a/chezmoi2/cmd/docs_noembeddocs.go b/chezmoi2/cmd/docs_noembeddocs.go new file mode 100644 index 000000000000..2d79e1d736b1 --- /dev/null +++ b/chezmoi2/cmd/docs_noembeddocs.go @@ -0,0 +1,27 @@ +// +build !nodocs +// +build noembeddocs + +package cmd + +import ( + "io/ioutil" + "os" + "path/filepath" +) + +// DocsDir is the directory containing docs when chezmoi is built without +// embedded docs. It should be an absolute path. +var DocsDir = "docs" + +func doc(filename string) ([]byte, error) { + return ioutil.ReadFile(filepath.Join(DocsDir, filename)) +} + +func docsFilenames() ([]string, error) { + f, err := os.Open(DocsDir) + if err != nil { + return nil, err + } + defer f.Close() + return f.Readdirnames(-1) +} diff --git a/chezmoi2/cmd/docscmd.go b/chezmoi2/cmd/docscmd.go new file mode 100644 index 000000000000..214e6dc9e717 --- /dev/null +++ b/chezmoi2/cmd/docscmd.go @@ -0,0 +1,87 @@ +// +build !nodocs + +package cmd + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/charmbracelet/glamour" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +func (c *Config) newDocsCmd() *cobra.Command { + docsCmd := &cobra.Command{ + Use: "docs [regexp]", + Short: "Print documentation", + Long: mustLongHelp("docs"), + Example: example("docs"), + Args: cobra.MaximumNArgs(1), + RunE: c.runDocsCmd, + Annotations: map[string]string{ + doesNotRequireValidConfig: "true", + }, + } + + return docsCmd +} + +func (c *Config) runDocsCmd(cmd *cobra.Command, args []string) error { + filename := "REFERENCE.md" + if len(args) > 0 { + pattern := args[0] + re, err := regexp.Compile(strings.ToLower(pattern)) + if err != nil { + return err + } + allDocsFilenames, err := docsFilenames() + if err != nil { + return err + } + var filenames []string + for _, fn := range allDocsFilenames { + if re.FindStringIndex(strings.ToLower(fn)) != nil { + filenames = append(filenames, fn) + } + } + switch { + case len(filenames) == 0: + return fmt.Errorf("%s: no matching files", pattern) + case len(filenames) == 1: + filename = filenames[0] + default: + return fmt.Errorf("%s: ambiguous pattern, matches %s", pattern, strings.Join(filenames, ", ")) + } + } + + documentData, err := doc(filename) + if err != nil { + return err + } + + width := 80 + if stdout, ok := c.stdout.(*os.File); ok && term.IsTerminal(int(stdout.Fd())) { + width, _, err = term.GetSize(int(stdout.Fd())) + if err != nil { + return err + } + } + + tr, err := glamour.NewTermRenderer( + glamour.WithStyles(glamour.ASCIIStyleConfig), + glamour.WithWordWrap(width), + ) + if err != nil { + return err + } + + renderedData, err := tr.RenderBytes(documentData) + if err != nil { + return err + } + + return c.writeOutput(renderedData) +} diff --git a/chezmoi2/cmd/doctorcmd.go b/chezmoi2/cmd/doctorcmd.go new file mode 100644 index 000000000000..9f4886edebf0 --- /dev/null +++ b/chezmoi2/cmd/doctorcmd.go @@ -0,0 +1,361 @@ +package cmd + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "text/tabwriter" + + "github.com/coreos/go-semver/semver" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/twpayne/go-shell" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoilog" +) + +// A checkResult is the result of a check. +type checkResult int + +const ( + checkSkipped checkResult = -1 // The check was skipped. + checkOK checkResult = 0 // The check completed and did not find any problems. + checkWarning checkResult = 1 // The check completed and found something that might indicate a problem. + checkError checkResult = 2 // The check completed and found a definite problem. + checkFailed checkResult = 3 // The check could not be completed. +) + +// A check is an individual check. +type check interface { + Name() string // Name returns the check's name. + Run() (checkResult, string) // Run runs the check. +} + +var checkResultStr = map[checkResult]string{ + checkSkipped: "skipped", + checkOK: "ok", + checkWarning: "warning", + checkError: "error", + checkFailed: "failed", +} + +// A binaryCheck checks that a binary called name is installed and optionally at +// least version minVersion. +type binaryCheck struct { + name string + binaryname string + ifNotExist checkResult + versionArgs []string + versionRx *regexp.Regexp + minVersion *semver.Version +} + +// A dirCheck checks that a directory exists. +type dirCheck struct { + name string + dirname string +} + +// A fileCheck checks that a file exists. +type fileCheck struct { + name string + filename string + ifNotExist checkResult +} + +// An osArchCheck checks that runtime.GOOS and runtime.GOARCH are supported. +type osArchCheck struct{} + +// A suspiciousEntriesCheck checks that a source directory does not contain any +// suspicious files. +type suspiciousEntriesCheck struct { + dirname string +} + +// A versionCheck checks the version information. +type versionCheck struct { + versionInfo VersionInfo + versionStr string +} + +func (c *Config) newDoctorCmd() *cobra.Command { + doctorCmd := &cobra.Command{ + Args: cobra.NoArgs, + Use: "doctor", + Short: "Check your system for potential problems", + Example: example("doctor"), + Long: mustLongHelp("doctor"), + RunE: c.runDoctorCmd, + Annotations: map[string]string{ + doesNotRequireValidConfig: "true", + runsCommands: "true", + }, + } + + return doctorCmd +} + +func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { + shell, _ := shell.CurrentUserShell() + editor, _ := c.editor() + checks := []check{ + &versionCheck{ + versionInfo: c.versionInfo, + versionStr: c.versionStr, + }, + &osArchCheck{}, + &fileCheck{ + name: "config-file", + filename: string(c.configFileAbsPath), + ifNotExist: checkWarning, + }, + &dirCheck{ + name: "source-dir", + dirname: string(c.sourceDirAbsPath), + }, + &suspiciousEntriesCheck{ + dirname: string(c.sourceDirAbsPath), + }, + &dirCheck{ + name: "dest-dir", + dirname: string(c.destDirAbsPath), + }, + &binaryCheck{ + name: "shell", + binaryname: shell, + }, + &binaryCheck{ + name: "editor", + binaryname: editor, + }, + &binaryCheck{ + name: "git-cli", + binaryname: c.Git.Command, + ifNotExist: checkWarning, + versionArgs: []string{"--version"}, + versionRx: regexp.MustCompile(`^git\s+version\s+(\d+\.\d+\.\d+)`), + }, + &binaryCheck{ + name: "merge-cli", + binaryname: c.Merge.Command, + ifNotExist: checkWarning, + }, + &binaryCheck{ + name: "gnupg-cli", + binaryname: "gpg", + versionArgs: []string{"--version"}, + versionRx: regexp.MustCompile(`^gpg\s+\(.*?\)\s+(\d+\.\d+\.\d+)`), + }, + &binaryCheck{ + name: "1password-cli", + binaryname: c.Onepassword.Command, + ifNotExist: checkWarning, + versionArgs: []string{"--version"}, + versionRx: regexp.MustCompile(`^(\d+\.\d+\.\d+)`), + }, + &binaryCheck{ + name: "bitwarden-cli", + binaryname: c.Bitwarden.Command, + ifNotExist: checkWarning, + versionArgs: []string{"--version"}, + versionRx: regexp.MustCompile(`^(\d+\.\d+\.\d+)`), + }, + &binaryCheck{ + name: "gopass-cli", + binaryname: c.Gopass.Command, + ifNotExist: checkWarning, + versionArgs: gopassVersionArgs, + versionRx: gopassVersionRx, + minVersion: &gopassMinVersion, + }, + &binaryCheck{ + name: "keepassxc-cli", + binaryname: c.Keepassxc.Command, + ifNotExist: checkWarning, + versionArgs: []string{"--version"}, + versionRx: regexp.MustCompile(`^(\d+\.\d+\.\d+)`), + }, + &fileCheck{ + name: "keepassxc-db", + filename: c.Keepassxc.Database, + ifNotExist: checkWarning, + }, + &binaryCheck{ + name: "lastpass-cli", + binaryname: c.Lastpass.Command, + ifNotExist: checkWarning, + versionArgs: lastpassVersionArgs, + versionRx: lastpassVersionRx, + minVersion: &lastpassMinVersion, + }, + &binaryCheck{ + name: "pass-cli", + binaryname: c.Pass.Command, + ifNotExist: checkWarning, + versionArgs: []string{"version"}, + versionRx: regexp.MustCompile(`(?m)=\s*v(\d+\.\d+\.\d+)`), + }, + &binaryCheck{ + name: "vault-cli", + binaryname: c.Vault.Command, + ifNotExist: checkWarning, + versionArgs: []string{"version"}, + versionRx: regexp.MustCompile(`^Vault\s+v(\d+\.\d+\.\d+)`), + }, + &binaryCheck{ + name: "secret-cli", + binaryname: c.Secret.Command, + ifNotExist: checkWarning, + }, + } + + worstResult := checkOK + resultWriter := tabwriter.NewWriter(c.stdout, 3, 0, 3, ' ', 0) + fmt.Fprint(resultWriter, "RESULT\tCHECK\tMESSAGE\n") + for _, check := range checks { + checkResult, message := check.Run() + fmt.Fprintf(resultWriter, "%s\t%s\t%s\n", checkResultStr[checkResult], check.Name(), message) + if checkResult > worstResult { + worstResult = checkResult + } + } + resultWriter.Flush() + + if worstResult > checkWarning { + return ErrExitCode(1) + } + + return nil +} + +func (c *binaryCheck) Name() string { + return c.name +} + +func (c *binaryCheck) Run() (checkResult, string) { + if c.binaryname == "" { + return checkWarning, "not set" + } + + path, err := exec.LookPath(c.binaryname) + switch { + case errors.Is(err, exec.ErrNotFound): + return c.ifNotExist, fmt.Sprintf("%s not found in $PATH", c.binaryname) + case err != nil: + return checkFailed, err.Error() + } + + if c.versionArgs == nil { + return checkOK, fmt.Sprintf("found %s", path) + } + + cmd := exec.Command(path, c.versionArgs...) + output, err := chezmoilog.LogCmdCombinedOutput(log.Logger, cmd) + if err != nil { + return checkFailed, err.Error() + } + + versionBytes := output + if c.versionRx != nil { + match := c.versionRx.FindSubmatch(versionBytes) + if len(match) != 2 { + return checkFailed, fmt.Sprintf("found %s, could not parse version from %s", path, versionBytes) + } + versionBytes = match[1] + } + version, err := semver.NewVersion(string(versionBytes)) + if err != nil { + return checkFailed, err.Error() + } + + if c.minVersion != nil && version.LessThan(*c.minVersion) { + return checkError, fmt.Sprintf("found %s, version %s, need %s", path, version, c.minVersion) + } + + return checkOK, fmt.Sprintf("found %s, version %s", path, version) +} + +func (c *dirCheck) Name() string { + return c.name +} + +func (c *dirCheck) Run() (checkResult, string) { + if _, err := ioutil.ReadDir(c.dirname); err != nil { + return checkError, fmt.Sprintf("%s: %v", c.dirname, err) + } + return checkOK, fmt.Sprintf("%s is a directory", c.dirname) +} + +func (c *fileCheck) Name() string { + return c.name +} + +func (c *fileCheck) Run() (checkResult, string) { + if c.filename == "" { + return checkWarning, "not set" + } + + _, err := ioutil.ReadFile(c.filename) + switch { + case os.IsNotExist(err): + return c.ifNotExist, fmt.Sprintf("%s does not exist", c.filename) + case err != nil: + return checkError, fmt.Sprintf("%s: %v", c.filename, err) + default: + return checkOK, fmt.Sprintf("%s is a file", c.filename) + } +} + +func (osArchCheck) Name() string { + return "os-arch" +} + +func (osArchCheck) Run() (checkResult, string) { + return checkOK, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) +} + +func (c *suspiciousEntriesCheck) Name() string { + return "suspicious-entries" +} + +func (c *suspiciousEntriesCheck) Run() (checkResult, string) { + // FIXME check that config file templates are in root + var suspiciousEntries []string + if err := filepath.Walk(c.dirname, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if chezmoi.SuspiciousSourceDirEntry(filepath.Base(path), info) { + suspiciousEntries = append(suspiciousEntries, path) + } + return nil + }); err != nil { + return checkError, err.Error() + } + if len(suspiciousEntries) > 0 { + return checkWarning, fmt.Sprintf("found suspicious entries in %s: %s", c.dirname, strings.Join(suspiciousEntries, ", ")) + } + + return checkOK, fmt.Sprintf("no suspicious entries found in %s", c.dirname) +} + +func (c *versionCheck) Name() string { + return "version" +} + +func (c *versionCheck) Run() (checkResult, string) { + if c.versionInfo.Version == "" || + c.versionInfo.Commit == "" || + c.versionInfo.Date == "" || + c.versionInfo.BuiltBy == "" { + return checkWarning, c.versionStr + } + return checkOK, c.versionStr +} diff --git a/chezmoi2/cmd/dumpcmd.go b/chezmoi2/cmd/dumpcmd.go new file mode 100644 index 000000000000..eeebec29fbc8 --- /dev/null +++ b/chezmoi2/cmd/dumpcmd.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type dumpCmdConfig struct { + include *chezmoi.IncludeSet + recursive bool +} + +func (c *Config) newDumpCmd() *cobra.Command { + dumpCmd := &cobra.Command{ + Use: "dump [target]...", + Short: "Generate a dump of the target state", + Long: mustLongHelp("dump"), + Example: example("dump"), + RunE: c.runDumpCmd, + Annotations: map[string]string{ + persistentStateMode: persistentStateModeEmpty, + }, + } + + flags := dumpCmd.Flags() + flags.VarP(c.dump.include, "include", "i", "include entry types") + flags.BoolVarP(&c.dump.recursive, "recursive", "r", c.dump.recursive, "recursive") + + return dumpCmd +} + +func (c *Config) runDumpCmd(cmd *cobra.Command, args []string) error { + dumpSystem := chezmoi.NewDumpSystem() + if err := c.applyArgs(dumpSystem, "", args, applyArgsOptions{ + include: c.dump.include, + recursive: c.dump.recursive, + umask: os.ModePerm, + }); err != nil { + return err + } + return c.marshal(dumpSystem.Data()) +} diff --git a/chezmoi2/cmd/editcmd.go b/chezmoi2/cmd/editcmd.go new file mode 100644 index 000000000000..8c95a4b060d1 --- /dev/null +++ b/chezmoi2/cmd/editcmd.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "io/ioutil" + "runtime" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type editCmdConfig struct { + Command string + Args []string + apply bool + include *chezmoi.IncludeSet +} + +func (c *Config) newEditCmd() *cobra.Command { + editCmd := &cobra.Command{ + Use: "edit targets...", + Short: "Edit the source state of a target", + Long: mustLongHelp("edit"), + Example: example("edit"), + RunE: c.makeRunEWithSourceState(c.runEditCmd), + Annotations: map[string]string{ + modifiesDestinationDirectory: "true", + modifiesSourceDirectory: "true", + persistentStateMode: persistentStateModeReadWrite, + requiresSourceDirectory: "true", + runsCommands: "true", + }, + } + + flags := editCmd.Flags() + flags.BoolVarP(&c.Edit.apply, "apply", "a", c.Edit.apply, "apply edit after editing") + flags.VarP(c.Edit.include, "include", "i", "include entry types") + + return editCmd +} + +func (c *Config) runEditCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + if len(args) == 0 { + if err := c.runEditor([]string{string(c.sourceDirAbsPath)}); err != nil { + return err + } + if c.Edit.apply { + if err := c.applyArgs(c.destSystem, c.destDirAbsPath, noArgs, applyArgsOptions{ + include: c.Edit.include, + recursive: true, + umask: c.Umask.FileMode(), + preApplyFunc: c.defaultPreApplyFunc, + }); err != nil { + return err + } + } + return nil + } + + targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ + mustBeInSourceState: true, + }) + if err != nil { + return err + } + + editorArgs := make([]string, 0, len(targetRelPaths)) + var decryptedDirAbsPath chezmoi.AbsPath + type transparentlyDecryptedFile struct { + sourceAbsPath chezmoi.AbsPath + decryptedAbsPath chezmoi.AbsPath + } + var transparentlyDecryptedFiles []transparentlyDecryptedFile + for _, targetRelPath := range targetRelPaths { + sourceStateEntry := sourceState.MustEntry(targetRelPath) + sourceRelPath := sourceStateEntry.SourceRelPath().RelPath() + var editorArg string + if sourceStateFile, ok := sourceStateEntry.(*chezmoi.SourceStateFile); ok && sourceStateFile.Attr.Encrypted { + if decryptedDirAbsPath == "" { + decryptedDir, err := ioutil.TempDir("", "chezmoi-decrypted") + if err != nil { + return err + } + decryptedDirAbsPath = chezmoi.AbsPath(decryptedDir) + defer func() { + _ = c.baseSystem.RemoveAll(decryptedDirAbsPath) + }() + if runtime.GOOS != "windows" { + if err := c.baseSystem.Chmod(decryptedDirAbsPath, 0o700); err != nil { + return err + } + } + } + // FIXME use RawContents and DecryptFile + decryptedAbsPath := decryptedDirAbsPath.Join(sourceRelPath) + contents, err := sourceStateFile.Contents() + if err != nil { + return err + } + if err := c.baseSystem.WriteFile(decryptedAbsPath, contents, 0o600); err != nil { + return err + } + transparentlyDecryptedFile := transparentlyDecryptedFile{ + sourceAbsPath: c.sourceDirAbsPath.Join(sourceRelPath), + decryptedAbsPath: decryptedAbsPath, + } + transparentlyDecryptedFiles = append(transparentlyDecryptedFiles, transparentlyDecryptedFile) + editorArg = string(decryptedAbsPath) + } else { + sourceAbsPath := c.sourceDirAbsPath.Join(sourceRelPath) + editorArg = string(sourceAbsPath) + } + editorArgs = append(editorArgs, editorArg) + } + + if err := c.runEditor(editorArgs); err != nil { + return err + } + + for _, transparentlyDecryptedFile := range transparentlyDecryptedFiles { + contents, err := c.encryption.EncryptFile(string(transparentlyDecryptedFile.decryptedAbsPath)) + if err != nil { + return err + } + if err := c.sourceSystem.WriteFile(transparentlyDecryptedFile.sourceAbsPath, contents, 0o666); err != nil { + return err + } + } + + if c.Edit.apply { + if err := c.applyArgs(c.destSystem, c.destDirAbsPath, args, applyArgsOptions{ + include: c.Edit.include, + recursive: false, + umask: c.Umask.FileMode(), + preApplyFunc: c.defaultPreApplyFunc, + }); err != nil { + return err + } + } + + return nil +} diff --git a/chezmoi2/cmd/editconfigcmd.go b/chezmoi2/cmd/editconfigcmd.go new file mode 100644 index 000000000000..f77a6e2a9641 --- /dev/null +++ b/chezmoi2/cmd/editconfigcmd.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +func (c *Config) newEditConfigCmd() *cobra.Command { + editConfigCmd := &cobra.Command{ + Use: "edit-config", + Short: "Edit the configuration file", + Long: mustLongHelp("edit-config"), + Example: example("edit-config"), + Args: cobra.NoArgs, + RunE: c.runEditConfigCmd, + Annotations: map[string]string{ + modifiesConfigFile: "true", + requiresConfigDirectory: "true", + runsCommands: "true", + }, + } + + return editConfigCmd +} + +func (c *Config) runEditConfigCmd(cmd *cobra.Command, args []string) error { + return c.runEditor([]string{string(c.configFileAbsPath)}) +} diff --git a/chezmoi2/cmd/executetemplatecmd.go b/chezmoi2/cmd/executetemplatecmd.go new file mode 100644 index 000000000000..896e894192f1 --- /dev/null +++ b/chezmoi2/cmd/executetemplatecmd.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "io/ioutil" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type executeTemplateCmdConfig struct { + init bool + promptBool map[string]string + promptInt map[string]int + promptString map[string]string +} + +func (c *Config) newExecuteTemplateCmd() *cobra.Command { + executeTemplateCmd := &cobra.Command{ + Use: "execute-template [template]...", + Short: "Execute the given template(s)", + Long: mustLongHelp("execute-template"), + Example: example("execute-template"), + RunE: c.makeRunEWithSourceState(c.runExecuteTemplateCmd), + } + + flags := executeTemplateCmd.Flags() + flags.BoolVarP(&c.executeTemplate.init, "init", "i", c.executeTemplate.init, "simulate chezmoi init") + flags.StringToStringVar(&c.executeTemplate.promptBool, "promptBool", c.executeTemplate.promptBool, "simulate promptBool") + flags.StringToIntVar(&c.executeTemplate.promptInt, "promptInt", c.executeTemplate.promptInt, "simulate promptInt") + flags.StringToStringVarP(&c.executeTemplate.promptString, "promptString", "p", c.executeTemplate.promptString, "simulate promptString") + + return executeTemplateCmd +} + +func (c *Config) runExecuteTemplateCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + promptBool := make(map[string]bool) + for key, valueStr := range c.executeTemplate.promptBool { + value, err := parseBool(valueStr) + if err != nil { + return err + } + promptBool[key] = value + } + if c.executeTemplate.init { + for name, f := range map[string]interface{}{ + "promptBool": func(prompt string) bool { + return promptBool[prompt] + }, + "promptInt": func(prompt string) int { + return c.executeTemplate.promptInt[prompt] + }, + "promptString": func(prompt string) string { + if value, ok := c.executeTemplate.promptString[prompt]; ok { + return value + } + return prompt + }, + } { + c.templateFuncs[name] = f + } + } + + if len(args) == 0 { + data, err := ioutil.ReadAll(c.stdin) + if err != nil { + return err + } + output, err := sourceState.ExecuteTemplateData("stdin", data) + if err != nil { + return err + } + return c.writeOutput(output) + } + + output := strings.Builder{} + for i, arg := range args { + result, err := sourceState.ExecuteTemplateData("arg"+strconv.Itoa(i+1), []byte(arg)) + if err != nil { + return err + } + if _, err := output.Write(result); err != nil { + return err + } + } + return c.writeOutputString(output.String()) +} diff --git a/chezmoi2/cmd/filemode.go b/chezmoi2/cmd/filemode.go new file mode 100644 index 000000000000..9f4bc74f9d1a --- /dev/null +++ b/chezmoi2/cmd/filemode.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" +) + +// A fileMode represents a file mode. It implements the +// github.com/spf13/pflag.Value interface for use as a command line flag. +type fileMode os.FileMode + +func (m *fileMode) FileMode() os.FileMode { + return os.FileMode(*m) +} + +func (m *fileMode) Set(s string) error { + mUint64, err := strconv.ParseUint(s, 8, 32) + if err != nil || os.FileMode(mUint64)&os.ModePerm != os.FileMode(mUint64) { + return fmt.Errorf("%s: invalid mode", s) + } + *m = fileMode(mUint64) + return nil +} + +func (m *fileMode) String() string { + return fmt.Sprintf("%03o", *m) +} + +func (m *fileMode) Type() string { + return "file mode" +} diff --git a/chezmoi2/cmd/filemode_test.go b/chezmoi2/cmd/filemode_test.go new file mode 100644 index 000000000000..cf84b94c52c4 --- /dev/null +++ b/chezmoi2/cmd/filemode_test.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileMode(t *testing.T) { + for _, tc := range []struct { + s string + expectedErr bool + expected fileMode + expectedString string + }{ + { + s: "0", + expected: 0, + expectedString: "000", + }, + { + s: "644", + expected: 0o644, + expectedString: "644", + }, + { + s: "755", + expected: 0o755, + expectedString: "755", + }, + { + s: "0", + expected: 0, + expectedString: "000", + }, + { + s: "-0", + expectedErr: true, + }, + { + s: "s", + expectedErr: true, + }, + { + s: "008", + expectedErr: true, + }, + { + s: "01000", + expectedErr: true, + }, + { + s: "-0", + expectedErr: true, + }, + } { + t.Run(tc.s, func(t *testing.T) { + var p fileMode + err := p.Set(tc.s) + if tc.expectedErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectedString, p.String()) + assert.Equal(t, "file mode", p.Type()) + } + }) + } +} diff --git a/chezmoi2/cmd/forgetcmd.go b/chezmoi2/cmd/forgetcmd.go new file mode 100644 index 000000000000..a3c4792ac34d --- /dev/null +++ b/chezmoi2/cmd/forgetcmd.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +func (c *Config) newForgetCmd() *cobra.Command { + forgetCmd := &cobra.Command{ + Use: "forget target...", + Aliases: []string{"unmanage"}, + Short: "Remove a target from the source state", + Long: mustLongHelp("forget"), + Example: example("forget"), + Args: cobra.MinimumNArgs(1), + RunE: c.makeRunEWithSourceState(c.runForgetCmd), + Annotations: map[string]string{ + modifiesSourceDirectory: "true", + }, + } + + return forgetCmd +} + +func (c *Config) runForgetCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + sourceAbsPaths, err := c.sourceAbsPaths(sourceState, args) + if err != nil { + return err + } + + for _, sourceAbsPath := range sourceAbsPaths { + if !c.force { + choice, err := c.prompt(fmt.Sprintf("Remove %s", sourceAbsPath), "ynqa") + if err != nil { + return err + } + switch choice { + case 'y': + case 'n': + continue + case 'q': + return nil + case 'a': + c.force = false + } + } + if err := c.sourceSystem.RemoveAll(sourceAbsPath); err != nil { + return err + } + } + + return nil +} diff --git a/chezmoi2/cmd/gitcmd.go b/chezmoi2/cmd/gitcmd.go new file mode 100644 index 000000000000..6de62c52867c --- /dev/null +++ b/chezmoi2/cmd/gitcmd.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +type gitCmdConfig struct { + Command string + AutoAdd bool + AutoCommit bool + AutoPush bool +} + +func (c *Config) newGitCmd() *cobra.Command { + gitCmd := &cobra.Command{ + Use: "git [arg]...", + Short: "Run git in the source directory", + Long: mustLongHelp("git"), + Example: example("git"), + RunE: c.runGitCmd, + Annotations: map[string]string{ + requiresSourceDirectory: "true", + runsCommands: "true", + }, + } + + return gitCmd +} + +func (c *Config) runGitCmd(cmd *cobra.Command, args []string) error { + return c.run(c.sourceDirAbsPath, c.Git.Command, args) +} diff --git a/chezmoi2/cmd/githubtemplatefuncs.go b/chezmoi2/cmd/githubtemplatefuncs.go new file mode 100644 index 000000000000..3b013f5d7186 --- /dev/null +++ b/chezmoi2/cmd/githubtemplatefuncs.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "context" + "net/http" + "os" + + "github.com/google/go-github/v33/github" + "golang.org/x/oauth2" +) + +type gitHubData struct { + client *github.Client + keysCache map[string][]*github.Key +} + +func (c *Config) gitHubKeysTemplateFunc(user string) []*github.Key { + if keys, ok := c.gitHub.keysCache[user]; ok { + return keys + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.gitHub.client == nil { + var httpClient *http.Client + for _, key := range []string{ + "CHEZMOI_GITHUB_ACCESS_TOKEN", + "GITHUB_ACCESS_TOKEN", + "GITHUB_TOKEN", + } { + if accessToken := os.Getenv(key); accessToken != "" { + httpClient = oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: accessToken, + })) + break + } + } + c.gitHub.client = github.NewClient(httpClient) + } + + var allKeys []*github.Key + opts := &github.ListOptions{ + PerPage: 100, + } + for { + keys, resp, err := c.gitHub.client.Users.ListKeys(ctx, user, opts) + if err != nil { + returnTemplateError(err) + return nil + } + allKeys = append(allKeys, keys...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + if c.gitHub.keysCache == nil { + c.gitHub.keysCache = make(map[string][]*github.Key) + } + c.gitHub.keysCache[user] = allKeys + return allKeys +} diff --git a/chezmoi2/cmd/gopasstemplatefuncs.go b/chezmoi2/cmd/gopasstemplatefuncs.go new file mode 100644 index 000000000000..8dbc7c326efb --- /dev/null +++ b/chezmoi2/cmd/gopasstemplatefuncs.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "bytes" + "fmt" + "os/exec" + "regexp" + + "github.com/coreos/go-semver/semver" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +var ( + // chezmoi uses gopass show --password which was added in + // https://github.com/gopasspw/gopass/commit/8fa13d84e3656cfc4ee6717f5f485c9e471ad996 + // and the first tag containing that commit is v1.6.1. + gopassMinVersion = semver.Version{Major: 1, Minor: 6, Patch: 1} + gopassVersionArgs = []string{"--version"} + gopassVersionRx = regexp.MustCompile(`gopass\s+(\d+\.\d+\.\d+)`) +) + +type gopassConfig struct { + Command string + versionOK bool + cache map[string]string +} + +func (c *Config) gopassOutput(args ...string) ([]byte, error) { + name := c.Gopass.Command + cmd := exec.Command(name, args...) + cmd.Stdin = c.stdin + cmd.Stderr = c.stderr + output, err := c.baseSystem.IdempotentCmdOutput(cmd) + if err != nil { + return nil, err + } + return output, nil +} + +func (c *Config) gopassTemplateFunc(id string) string { + if !c.Gopass.versionOK { + if err := c.gopassVersionCheck(); err != nil { + returnTemplateError(err) + return "" + } + c.Gopass.versionOK = true + } + + if s, ok := c.Gopass.cache[id]; ok { + return s + } + + args := []string{"show", "--password", id} + output, err := c.gopassOutput(args...) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w", c.Gopass.Command, chezmoi.ShellQuoteArgs(args), err)) + return "" + } + + var password string + if index := bytes.IndexByte(output, '\n'); index != -1 { + password = string(output[:index]) + } else { + password = string(output) + } + + if c.Gopass.cache == nil { + c.Gopass.cache = make(map[string]string) + } + c.Gopass.cache[id] = password + return password +} + +func (c *Config) gopassVersionCheck() error { + output, err := c.gopassOutput("--version") + if err != nil { + return err + } + m := gopassVersionRx.FindSubmatch(output) + if m == nil { + return fmt.Errorf("%s: could not extract version", output) + } + version, err := semver.NewVersion(string(m[1])) + if err != nil { + return err + } + if version.LessThan(gopassMinVersion) { + return fmt.Errorf("version %s found, need version %s or later", version, gopassMinVersion) + } + return nil +} diff --git a/chezmoi2/cmd/helpcmd.go b/chezmoi2/cmd/helpcmd.go new file mode 100644 index 000000000000..204a1e267737 --- /dev/null +++ b/chezmoi2/cmd/helpcmd.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +func (c *Config) newHelpCmd() *cobra.Command { + helpCmd := &cobra.Command{ + Use: "help [command]", + Short: "Print help about a command", + Long: mustLongHelp("help"), + Example: example("help"), + RunE: c.runHelpCmd, + } + + return helpCmd +} + +func (c *Config) runHelpCmd(cmd *cobra.Command, args []string) error { + subCmd, _, err := cmd.Root().Find(args) + if err != nil { + return err + } + if subCmd == nil { + return fmt.Errorf("unknown command: %s", strings.Join(args, " ")) + } + return subCmd.Help() +} diff --git a/chezmoi2/cmd/helps.gen.go b/chezmoi2/cmd/helps.gen.go new file mode 100644 index 000000000000..08826fa56753 --- /dev/null +++ b/chezmoi2/cmd/helps.gen.go @@ -0,0 +1,536 @@ +// Code generated by github.com/twpayne/chezmoi/internal/cmd/generate-helps. DO NOT EDIT. + +package cmd + +type help struct { + long string + example string +} + +var helps = map[string]help{ + "add": { + long: "" + + "Description:\n" + + " Add *targets* to the source state. If any target is already in the source\n" + + " state, then its source state is replaced with its current state in the\n" + + " destination directory. The `add` command accepts additional flags:\n" + + "\n" + + " `--autotemplate`\n" + + "\n" + + " Automatically generate a template by replacing strings with variable names\n" + + " from the `data` section of the config file. Longer substitutions occur\n" + + " before shorter ones. This implies the `--template` option.\n" + + "\n" + + " `-e`, `--empty`\n" + + "\n" + + " Set the `empty` attribute on added files.\n" + + "\n" + + " `-f`, `--force`\n" + + "\n" + + " Add *targets*, even if doing so would cause a source template to be\n" + + " overwritten.\n" + + "\n" + + " `-x`, `--exact`\n" + + "\n" + + " Set the `exact` attribute on added directories.\n" + + "\n" + + " `-p`, `--prompt`\n" + + "\n" + + " Interactively prompt before adding each file.\n" + + "\n" + + " `-r`, `--recursive`\n" + + "\n" + + " Recursively add all files, directories, and symlinks.\n" + + "\n" + + " `-T`, `--template`\n" + + "\n" + + " Set the `template` attribute on added files and symlinks.", + example: "" + + " chezmoi add ~/.bashrc\n" + + " chezmoi add ~/.gitconfig --template\n" + + " chezmoi add ~/.vim --recursive\n" + + " chezmoi add ~/.oh-my-zsh --exact --recursive", + }, + "apply": { + long: "" + + "Description:\n" + + " Ensure that *targets* are in the target state, updating them if necessary.\n" + + " If no targets are specified, the state of all targets are ensured.", + example: "" + + " chezmoi apply\n" + + " chezmoi apply --dry-run --verbose\n" + + " chezmoi apply ~/.bashrc", + }, + "archive": { + long: "" + + "Description:\n" + + " Generate a tar archive of the target state. This can be piped into `tar` to\n" + + " inspect the target state.\n" + + "\n" + + " `--output`, `-o` *filename*\n" + + "\n" + + " Write the output to *filename* instead of stdout.", + example: "" + + " chezmoi archive | tar tvf -\n" + + " chezmoi archive --output=dotfiles.tar", + }, + "cat": { + long: "" + + "Description:\n" + + " Write the target state of *targets* to stdout. *targets* must be files or\n" + + " symlinks. For files, the target file contents are written. For symlinks, the\n" + + " target target is written.", + example: "" + + " chezmoi cat ~/.bashrc", + }, + "cd": { + long: "" + + "Description:\n" + + " Launch a shell in the source directory. chezmoi will launch the command set\n" + + " by the `cd.command` configuration variable with any extra arguments\n" + + " specified by `cd.args`. If this is not set, chezmoi will attempt to detect\n" + + " your shell and will finally fall back to an OS-specific default.", + example: "" + + " chezmoi cd", + }, + "chattr": { + long: "" + + "Description:\n" + + " Change the attributes of *targets*. *attributes* specifies which attributes\n" + + " to modify. Add attributes by specifying them or their abbreviations\n" + + " directly, optionally prefixed with a plus sign (`+`). Remove attributes by\n" + + " prefixing them or their attributes with the string `no` or a minus sign (`-\n" + + " `). The available attributes and their abbreviations are:\n" + + "\n" + + " ATTRIBUTE | ABBREVIATION\n" + + " -------------+---------------\n" + + " empty | e\n" + + " encrypted | none\n" + + " exact | none\n" + + " executable | x\n" + + " private | p\n" + + " template | t\n" + + "\n" + + " Multiple attributes modifications may be specified by separating them with a\n" + + " comma (`,`).", + example: "" + + " chezmoi chattr template ~/.bashrc\n" + + " chezmoi chattr noempty ~/.profile\n" + + " chezmoi chattr private,template ~/.netrc", + }, + "completion": { + long: "" + + "Description:\n" + + " Generate shell completion code for the specified shell (`bash`, `fish`,\n" + + " `powershell`, or `zsh`).\n" + + "\n" + + " `--output`, `-o` *filename*\n" + + "\n" + + " Write the shell completion code to *filename* instead of stdout.", + example: "" + + " chezmoi completion bash\n" + + " chezmoi completion fish --output ~/.config/fish/completions/chezmoi.fish", + }, + "data": { + long: "" + + "Description:\n" + + " Write the computed template data in JSON format to stdout. The `data`\n" + + " command accepts additional flags:\n" + + "\n" + + " `-f`, `--format` *format*\n" + + "\n" + + " Print the computed template data in the given format. The accepted formats\n" + + " are `json` (JSON), `toml` (TOML), and `yaml` (YAML).", + example: "" + + " chezmoi data\n" + + " chezmoi data --format=yaml", + }, + "diff": { + long: "" + + "Description:\n" + + " Print the difference between the target state and the destination state for\n" + + " *targets*. If no targets are specified, print the differences for all\n" + + " targets.\n" + + "\n" + + " If a `diff.pager` command is set in the configuration file then the output\n" + + " will be piped into it.\n" + + "\n" + + " `-f`, `--format` *format*\n" + + "\n" + + " Print the diff in *format*. The format can be set with the `diff.format`\n" + + " variable in the configuration file. Valid formats are:\n" + + "\n" + + " ##### `chezmoi`\n" + + "\n" + + " A mix of unified diffs and pseudo shell commands, including scripts,\n" + + " equivalent to `chezmoi apply --dry-run --verbose`.\n" + + "\n" + + " ##### `git`\n" + + "\n" + + " A git format diff https://git-scm.com/docs/diff-format, excluding scripts. In\n" + + " version 2.0.0 of chezmoi, `git` format diffs will become the default and\n" + + " include scripts and the `chezmoi` format will be removed.\n" + + "\n" + + " `--no-pager`\n" + + "\n" + + " Do not use the pager.", + example: "" + + " chezmoi diff\n" + + " chezmoi diff ~/.bashrc\n" + + " chezmoi diff --format=git", + }, + "docs": { + long: "" + + "Description:\n" + + " Print the documentation page matching the regular expression *regexp*.\n" + + " Matching is case insensitive. If no pattern is given, print `REFERENCE.md`.", + example: "" + + " chezmoi docs\n" + + " chezmoi docs faq\n" + + " chezmoi docs howto", + }, + "doctor": { + long: "" + + "Description:\n" + + " Check for potential problems.", + example: "" + + " chezmoi doctor", + }, + "dump": { + long: "" + + "Description:\n" + + " Dump the target state in JSON format. If no targets are specified, then the\n" + + " entire target state. The `dump` command accepts additional arguments:\n" + + "\n" + + " `-f`, `--format` *format*\n" + + "\n" + + " Print the target state in the given format. The accepted formats are `json`\n" + + " (JSON) and `yaml` (YAML).", + example: "" + + " chezmoi dump ~/.bashrc\n" + + " chezmoi dump --format=yaml", + }, + "edit": { + long: "" + + "Description:\n" + + " Edit the source state of *targets*, which must be files or symlinks. If no\n" + + " targets are given the the source directory itself is opened with `$EDITOR`.\n" + + " The `edit` command accepts additional arguments:\n" + + "\n" + + " `-a`, `--apply`\n" + + "\n" + + " Apply target immediately after editing. Ignored if there are no targets.\n" + + "\n" + + " `-d`, `--diff`\n" + + "\n" + + " Print the difference between the target state and the actual state after\n" + + " editing.. Ignored if there are no targets.\n" + + "\n" + + " `-p`, `--prompt`\n" + + "\n" + + " Prompt before applying each target.. Ignored if there are no targets.", + example: "" + + " chezmoi edit ~/.bashrc\n" + + " chezmoi edit ~/.bashrc --apply --prompt\n" + + " chezmoi edit", + }, + "edit-config": { + long: "" + + "Description:\n" + + " Edit the configuration file.\n" + + "\n" + + " `edit-config` examples\n" + + "\n" + + " chezmoi edit-config", + }, + "execute-template": { + long: "" + + "Description:\n" + + " Execute *templates*. This is useful for testing templates or for calling\n" + + " chezmoi from other scripts. *templates* are interpreted as literal\n" + + " templates, with no whitespace added to the output between arguments. If no\n" + + " templates are specified, the template is read from stdin.\n" + + "\n" + + " `--init`, `-i`\n" + + "\n" + + " Include simulated functions only available during `chezmoi init`.\n" + + "\n" + + " `--output`, `-o` *filename*\n" + + "\n" + + " Write the output to *filename* instead of stdout.\n" + + "\n" + + " `--promptBool` *pairs*\n" + + "\n" + + " Simulate the `promptBool` function with a function that returns values from\n" + + " *pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If\n" + + " `promptBool` is called with a *prompt* that does not match any of *pairs*,\n" + + " then it returns false.\n" + + "\n" + + " `--promptInt`, `-p` *pairs*\n" + + "\n" + + " Simulate the `promptInt` function with a function that returns values from\n" + + " *pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If\n" + + " `promptInt` is called with a *prompt* that does not match any of *pairs*,\n" + + " then it returns zero.\n" + + "\n" + + " `--promptString`, `-p` *pairs*\n" + + "\n" + + " Simulate the `promptString` function with a function that returns values\n" + + " from *pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs.\n" + + " If `promptString` is called with a *prompt* that does not match any of\n" + + " *pairs*, then it returns *prompt* unchanged.\n" + + "\n" + + " `execute-template` examples\n" + + "\n" + + " chezmoi execute-template '{{ .chezmoi.sourceDir }}'\n" + + " chezmoi execute-template '{{ .chezmoi.os }}' / '{{ .chezmoi.arch }}'\n" + + " echo '{{ .chezmoi | toJson }}' | chezmoi execute-template\n" + + " chezmoi execute-template --init --promptString email=john@home.org <\n" + + " ~/.local/share/chezmoi/.chezmoi.toml.tmpl", + }, + "forget": { + long: "" + + "Description:\n" + + " Remove *targets* from the source state, i.e. stop managing them.", + example: "" + + " chezmoi forget ~/.bashrc", + }, + "git": { + long: "" + + "Description:\n" + + " Run `git` *arguments* in the source directory. Note that flags in\n" + + " *arguments* must occur after `--` to prevent chezmoi from interpreting them.", + example: "" + + " chezmoi git add .\n" + + " chezmoi git add dot_gitconfig\n" + + " chezmoi git -- commit -m \"Add .gitconfig\"", + }, + "help": { + long: "" + + "Description:\n" + + " Print the help associated with *command*.", + }, + "hg": { + long: "" + + "Description:\n" + + " Run `hg` *arguments* in the source directory. Note that flags in *arguments*\n" + + " must occur after `--` to prevent chezmoi from interpreting them.", + example: "" + + " chezmoi hg -- pull --rebase --update", + }, + "import": { + long: "" + + "Description:\n" + + " Import the source state from an archive file in to a directory in the source\n" + + " state. This is primarily used to make subdirectories of your home directory\n" + + " exactly match the contents of a downloaded archive. You will generally\n" + + " always want to set the `--destination`, `--exact`, and `--remove-destination`\n" + + " flags.\n" + + "\n" + + " The only supported archive format is `.tar.gz`.\n" + + "\n" + + " `--destination` *directory*\n" + + "\n" + + " Set the destination (in the source state) where the archive will be\n" + + " imported.\n" + + "\n" + + " `-x`, `--exact`\n" + + "\n" + + " Set the `exact` attribute on all imported directories.\n" + + "\n" + + " `-r`, `--remove-destination`\n" + + "\n" + + " Remove destination (in the source state) before importing.\n" + + "\n" + + " `--strip-components` *n*\n" + + "\n" + + " Strip *n* leading components from paths.", + example: "" + + " curl -s -L -o oh-my-zsh-master.tar.gz https://github.com/robbyrussell/oh-my-\n" + + "zsh/archive/master.tar.gz\n" + + " chezmoi import --strip-components 1 --destination ~/.oh-my-zsh oh-my-zsh-master.tar.gz", + }, + "init": { + long: "" + + "Description:\n" + + " Setup the source directory and update the destination directory to match the\n" + + " target state.\n" + + "\n" + + " First, if the source directory is not already contain a repository, then if\n" + + " *repo* is given it is checked out into the source directory, otherwise a new\n" + + " repository is initialized in the source directory.\n" + + "\n" + + " Second, if a file called `.chezmoi.format.tmpl` exists, where `format` is\n" + + " one of the supported file formats (e.g. `json`, `toml`, or `yaml`) then a\n" + + " new configuration file is created using that file as a template.\n" + + "\n" + + " Finally, if the `--apply` flag is passed, `chezmoi apply` is run.\n" + + "\n" + + " `--apply`\n" + + "\n" + + " Run `chezmoi apply` after checking out the repo and creating the config\n" + + " file. This is `false` by default.", + example: "" + + " chezmoi init https://github.com/user/dotfiles.git\n" + + " chezmoi init https://github.com/user/dotfiles.git --apply", + }, + "manage": { + long: "" + + "Description:\n" + + " `manage` is an alias for `add` for symmetry with `unmanage`.", + }, + "managed": { + long: "" + + "Description:\n" + + " List all managed entries in the destination directory in alphabetical order.\n" + + "\n" + + " `-i`, `--include` *types*\n" + + "\n" + + " Only list entries of type *types*. *types* is a comma-separated list of types\n" + + " of entry to include. Valid types are `dirs`, `files`, and `symlinks` which\n" + + " can be abbreviated to `d`, `f`, and `s` respectively. By default, `manage`\n" + + " will list entries of all types.", + example: "" + + " chezmoi managed\n" + + " chezmoi managed --include=files\n" + + " chezmoi managed --include=files,symlinks\n" + + " chezmoi managed -i d\n" + + " chezmoi managed -i d,f", + }, + "merge": { + long: "" + + "Description:\n" + + " Perform a three-way merge between the destination state, the source state,\n" + + " and the target state. The merge tool is defined by the `merge.command`\n" + + " configuration variable, and defaults to `vimdiff`. If multiple targets are\n" + + " specified the merge tool is invoked for each target. If the target state\n" + + " cannot be computed (for example if source is a template containing errors or\n" + + " an encrypted file that cannot be decrypted) a two-way merge is performed\n" + + " instead.", + example: "" + + " chezmoi merge ~/.bashrc", + }, + "purge": { + long: "" + + "Description:\n" + + " Remove chezmoi's configuration, state, and source directory, but leave the\n" + + " target state intact.\n" + + "\n" + + " `-f`, `--force`\n" + + "\n" + + " Remove without prompting.", + example: "" + + " chezmoi purge\n" + + " chezmoi purge --force", + }, + "remove": { + long: "" + + "Description:\n" + + " Remove *targets* from both the source state and the destination directory.\n" + + "\n" + + " `-f`, `--force`\n" + + "\n" + + " Remove without prompting.", + }, + "rm": { + long: "" + + "Description:\n" + + " `rm` is an alias for `remove`.", + }, + "secret": { + long: "" + + "Description:\n" + + " Run a secret manager's CLI, passing any extra arguments to the secret\n" + + " manager's CLI. This is primarily for verifying chezmoi's integration with\n" + + " your secret manager. Normally you would use template functions to retrieve\n" + + " secrets. Note that if you want to pass flags to the secret manager's CLI you\n" + + " will need to separate them with `--` to prevent chezmoi from interpreting\n" + + " them.\n" + + "\n" + + " To get a full list of available commands run:\n" + + "\n" + + " chezmoi secret help", + example: "" + + " chezmoi secret bitwarden list items\n" + + " chezmoi secret keyring set --service service --user user\n" + + " chezmoi secret keyring get --service service --user user\n" + + " chezmoi secret lastpass ls\n" + + " chezmoi secret lastpass -- show --format=json id\n" + + " chezmoi secret onepassword list items\n" + + " chezmoi secret onepassword get item id\n" + + " chezmoi secret pass show id\n" + + " chezmoi secret vault -- kv get -format=json id", + }, + "source": { + long: "" + + "Description:\n" + + " Execute the source version control system in the source directory with\n" + + " *args*. Note that any flags for the source version control system must be\n" + + " separated with a `--` to stop chezmoi from reading them.", + example: "" + + " chezmoi source init\n" + + " chezmoi source add .\n" + + " chezmoi source commit -- -m \"Initial commit\"", + }, + "source-path": { + long: "" + + "Description:\n" + + " Print the path to each target's source state. If no targets are specified\n" + + " then print the source directory.\n" + + "\n" + + " `source-path` examples\n" + + "\n" + + " chezmoi source-path\n" + + " chezmoi source-path ~/.bashrc", + }, + "unmanage": { + long: "" + + "Description:\n" + + " `unmanage` is an alias for `forget` for symmetry with `manage`.", + }, + "unmanaged": { + long: "" + + "Description:\n" + + " List all unmanaged files in the destination directory.", + example: "" + + " chezmoi unmanaged", + }, + "update": { + long: "" + + "Description:\n" + + " Pull changes from the source VCS and apply any changes.", + example: "" + + " chezmoi update", + }, + "upgrade": { + long: "" + + "Description:\n" + + " Upgrade chezmoi by downloading and installing the latest released version.\n" + + " This will call the GitHub API to determine if there is a new version of\n" + + " chezmoi available, and if so, download and attempt to install it in the same\n" + + " way as chezmoi was previously installed.\n" + + "\n" + + " If chezmoi was installed with a package manager (`dpkg` or `rpm`) then\n" + + " `upgrade` will download a new package and install it, using `sudo` if it is\n" + + " installed. Otherwise, chezmoi will download the latest executable and\n" + + " replace the existing executable with the new version.\n" + + "\n" + + " If the `CHEZMOI_GITHUB_API_TOKEN` environment variable is set, then its\n" + + " value will be used to authenticate requests to the GitHub API, otherwise\n" + + " unauthenticated requests are used which are subject to stricter rate\n" + + " limiting https://developer.github.com/v3/#rate-limiting. Unauthenticated\n" + + " requests should be sufficient for most cases.", + example: "" + + " chezmoi upgrade", + }, + "verify": { + long: "" + + "Description:\n" + + " Verify that all *targets* match their target state. chezmoi exits with code\n" + + " 0 (success) if all targets match their target state, or 1 (failure)\n" + + " otherwise. If no targets are specified then all targets are checked.", + example: "" + + " chezmoi verify\n" + + " chezmoi verify ~/.bashrc", + }, +} diff --git a/chezmoi2/cmd/importcmd.go b/chezmoi2/cmd/importcmd.go new file mode 100644 index 000000000000..f1a003d9b0b1 --- /dev/null +++ b/chezmoi2/cmd/importcmd.go @@ -0,0 +1,102 @@ +package cmd + +// LATER add zip import + +import ( + "archive/tar" + "bytes" + "compress/bzip2" + "compress/gzip" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type importCmdConfig struct { + destination string + exact bool + include *chezmoi.IncludeSet + removeDestination bool + stripComponents int +} + +func (c *Config) newImportCmd() *cobra.Command { + importCmd := &cobra.Command{ + Use: "import archive", + Short: "Import a tar archive into the source state", + Long: mustLongHelp("import"), + Example: example("import"), + Args: cobra.MaximumNArgs(1), + RunE: c.makeRunEWithSourceState(c.runImportCmd), + Annotations: map[string]string{ + modifiesSourceDirectory: "true", + persistentStateMode: persistentStateModeReadWrite, + requiresSourceDirectory: "true", + }, + } + + flags := importCmd.Flags() + flags.StringVarP(&c._import.destination, "destination", "d", c._import.destination, "destination prefix") + flags.BoolVarP(&c._import.exact, "exact", "x", c._import.exact, "import directories exactly") + flags.VarP(c._import.include, "include", "i", "include entry types") + flags.BoolVarP(&c._import.removeDestination, "remove-destination", "r", c._import.removeDestination, "remove destination before import") + flags.IntVar(&c._import.stripComponents, "strip-components", c._import.stripComponents, "strip components") + + return importCmd +} + +func (c *Config) runImportCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + var r io.Reader + if len(args) == 0 { + r = c.stdin + } else { + absPath, err := chezmoi.NewAbsPathFromExtPath(args[0], c.homeDirAbsPath) + if err != nil { + return err + } + data, err := c.baseSystem.ReadFile(absPath) + if err != nil { + return err + } + r = bytes.NewReader(data) + switch base := strings.ToLower(absPath.Base()); { + case strings.HasSuffix(base, ".tar.gz") || strings.HasSuffix(base, ".tgz"): + r, err = gzip.NewReader(r) + if err != nil { + return err + } + case strings.HasSuffix(base, ".tar.bz2"): + r = bzip2.NewReader(r) + case strings.HasSuffix(base, ".tar"): + default: + return fmt.Errorf("unknown format: %s", base) + } + } + rootAbsPath, err := chezmoi.NewAbsPathFromExtPath(c._import.destination, c.homeDirAbsPath) + if err != nil { + return err + } + tarReaderSystem, err := chezmoi.NewTARReaderSystem(tar.NewReader(r), chezmoi.TARReaderSystemOptions{ + RootAbsPath: rootAbsPath, + StripComponents: c._import.stripComponents, + }) + if err != nil { + return err + } + var removeDir chezmoi.RelPath + if c._import.removeDestination { + removeDir, err = rootAbsPath.TrimDirPrefix(c.destDirAbsPath) + if err != nil { + return err + } + } + return sourceState.Add(c.sourceSystem, c.persistentState, tarReaderSystem, tarReaderSystem.FileInfos(), &chezmoi.AddOptions{ + Exact: c._import.exact, + Include: c._import.include, + RemoveDir: removeDir, + }) +} diff --git a/chezmoi2/cmd/importcmd_test.go b/chezmoi2/cmd/importcmd_test.go new file mode 100644 index 000000000000..53448ac4efce --- /dev/null +++ b/chezmoi2/cmd/importcmd_test.go @@ -0,0 +1,167 @@ +package cmd + +import ( + "archive/tar" + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestImportCmd(t *testing.T) { + b := &bytes.Buffer{} + w := tar.NewWriter(b) + assert.NoError(t, w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeDir, + Name: "archive/", + Mode: 0o777, + })) + assert.NoError(t, w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeDir, + Name: "archive/.dir/", + Mode: 0o777, + })) + data := []byte("# contents of archive/.dir/.file\n") + assert.NoError(t, w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: "archive/.dir/.file", + Size: int64(len(data)), + Mode: 0o666, + })) + _, err := w.Write(data) + assert.NoError(t, err) + linkname := ".file" + assert.NoError(t, w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeSymlink, + Name: "archive/.dir/.symlink", + Linkname: linkname, + })) + require.NoError(t, w.Close()) + + for _, tc := range []struct { + args []string + extraRoot interface{} + tests []interface{} + }{ + { + args: []string{ + "--strip-components=1", + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/dot_file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666), + vfst.TestContentsString("# contents of archive/.dir/.file\n"), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/symlink_dot_symlink", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666), + vfst.TestContentsString(".file\n"), + ), + }, + }, + { + args: []string{ + "--destination=~/dir", + "--strip-components=1", + }, + extraRoot: map[string]interface{}{ + "/home/user/.local/share/chezmoi/dir": &vfst.Dir{Perm: 0o777}, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir/dot_file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666), + vfst.TestContentsString("# contents of archive/.dir/.file\n"), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir/symlink_dot_symlink", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666), + vfst.TestContentsString(".file\n"), + ), + }, + }, + { + args: []string{ + "--destination=~/dir", + "--remove-destination", + "--strip-components=1", + }, + extraRoot: map[string]interface{}{ + "/home/user/.local/share/chezmoi/dir/file": "# contents of dir/file\n", + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dir/file", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir/dot_file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666), + vfst.TestContentsString("# contents of archive/.dir/.file\n"), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir/symlink_dot_symlink", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666), + vfst.TestContentsString(".file\n"), + ), + }, + }, + { + args: []string{ + "--destination=~/dir", + "--exact", + "--strip-components=1", + }, + extraRoot: map[string]interface{}{ + "/home/user/.local/share/chezmoi/dir": &vfst.Dir{Perm: 0o777}, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dir/exact_dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dir/exact_dot_dir/dot_file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666), + vfst.TestContentsString("# contents of archive/.dir/.file\n"), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dir/exact_dot_dir/symlink_dot_symlink", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666), + vfst.TestContentsString(".file\n"), + ), + }, + }, + } { + t.Run(strings.Join(tc.args, "_"), func(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]interface{}{ + "/home/user": &vfst.Dir{Perm: 0o777}, + }, func(fs vfs.FS) { + if tc.extraRoot != nil { + require.NoError(t, vfst.NewBuilder().Build(fs, tc.extraRoot)) + } + c := newTestConfig(t, fs, withStdin(bytes.NewReader(b.Bytes()))) + require.NoError(t, c.execute(append([]string{"import"}, tc.args...))) + vfst.RunTests(t, fs, "", tc.tests...) + }) + }) + } +} diff --git a/chezmoi2/cmd/initcmd.go b/chezmoi2/cmd/initcmd.go new file mode 100644 index 000000000000..c2b10f3592b7 --- /dev/null +++ b/chezmoi2/cmd/initcmd.go @@ -0,0 +1,306 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "regexp" + "runtime" + "strconv" + "strings" + "text/template" + + "github.com/go-git/go-git/v5" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type initCmdConfig struct { + apply bool + depth int + ignoreEncrypted bool + oneShot bool + purge bool + purgeBinary bool +} + +var dotfilesRepoGuesses = []struct { + rx *regexp.Regexp + format string +}{ + { + rx: regexp.MustCompile(`\A[-0-9A-Za-z]+\z`), + format: "https://github.com/%s/dotfiles.git", + }, + { + rx: regexp.MustCompile(`\A[-0-9A-Za-z]+/[-0-9A-Za-z]+\.git\z`), + format: "https://github.com/%s", + }, + { + rx: regexp.MustCompile(`\A[-0-9A-Za-z]+/[-0-9A-Za-z]+\z`), + format: "https://github.com/%s.git", + }, + { + rx: regexp.MustCompile(`\A[-.0-9A-Za-z]+/[-0-9A-Za-z]+\z`), + format: "https://%s/dotfiles.git", + }, + { + rx: regexp.MustCompile(`\A[-.0-9A-Za-z]+/[-0-9A-Za-z]+/[-0-9A-Za-z]+\z`), + format: "https://%s.git", + }, + { + rx: regexp.MustCompile(`\A[-.0-9A-Za-z]+/[-0-9A-Za-z]+/[-0-9A-Za-z]+\.git\z`), + format: "https://%s", + }, + { + rx: regexp.MustCompile(`\Asr\.ht/~[-0-9A-Za-z]+\z`), + format: "https://git.%s/dotfiles", + }, + { + rx: regexp.MustCompile(`\Asr\.ht/~[-0-9A-Za-z]+/[-0-9A-Za-z]+\z`), + format: "https://git.%s", + }, +} + +func (c *Config) newInitCmd() *cobra.Command { + initCmd := &cobra.Command{ + Args: cobra.MaximumNArgs(1), + Use: "init [repo]", + Short: "Setup the source directory and update the destination directory to match the target state", + Long: mustLongHelp("init"), + Example: example("init"), + RunE: c.runInitCmd, + Annotations: map[string]string{ + modifiesDestinationDirectory: "true", + persistentStateMode: persistentStateModeReadWrite, + requiresSourceDirectory: "true", + runsCommands: "true", + }, + } + + flags := initCmd.Flags() + flags.BoolVarP(&c.init.apply, "apply", "a", c.init.apply, "update destination directory") + flags.IntVarP(&c.init.depth, "depth", "d", c.init.depth, "create a shallow clone") + flags.BoolVar(&c.init.ignoreEncrypted, "ignore-encrypted", c.init.ignoreEncrypted, "ignore encrypted files") + flags.BoolVar(&c.init.oneShot, "one-shot", c.init.oneShot, "one shot") + flags.BoolVarP(&c.init.purge, "purge", "p", c.init.purge, "purge config and source directories") + flags.BoolVarP(&c.init.purgeBinary, "purge-binary", "P", c.init.purgeBinary, "purge chezmoi binary") + + return initCmd +} + +func (c *Config) runInitCmd(cmd *cobra.Command, args []string) error { + if c.init.oneShot { + c.force = true + c.init.apply = true + c.init.depth = 1 + c.init.purge = true + } + + if len(args) == 0 { + switch useBuiltinGit, err := c.useBuiltinGit(); { + case err != nil: + return err + case useBuiltinGit: + rawSourceDir, err := c.baseSystem.RawPath(c.sourceDirAbsPath) + if err != nil { + return err + } + isBare := false + _, err = git.PlainInit(string(rawSourceDir), isBare) + return err + default: + return c.run(c.sourceDirAbsPath, c.Git.Command, []string{"init"}) + } + } + + // Clone repo into source directory if it does not already exist. + switch _, err := c.baseSystem.Stat(c.sourceDirAbsPath.Join(chezmoi.RelPath(".git"))); { + case os.IsNotExist(err): + rawSourceDir, err := c.baseSystem.RawPath(c.sourceDirAbsPath) + if err != nil { + return err + } + + dotfilesRepoURL := guessDotfilesRepoURL(args[0]) + switch useBuiltinGit, err := c.useBuiltinGit(); { + case err != nil: + return err + case useBuiltinGit: + isBare := false + if _, err := git.PlainClone(string(rawSourceDir), isBare, &git.CloneOptions{ + URL: dotfilesRepoURL, + Depth: c.init.depth, + RecurseSubmodules: git.DefaultSubmoduleRecursionDepth, + }); err != nil { + return err + } + default: + args := []string{ + "clone", + "--recurse-submodules", + } + if c.init.depth != 0 { + args = append(args, + "--depth", strconv.Itoa(c.init.depth), + ) + } + args = append(args, + dotfilesRepoURL, + string(rawSourceDir), + ) + if err := c.run("", c.Git.Command, args); err != nil { + return err + } + } + case err != nil: + return err + } + + // Find config template, execute it, and create config file. + filename, ext, data, err := c.findConfigTemplate() + if err != nil { + return err + } + var configFileContents []byte + if filename != "" { + configFileContents, err = c.createConfigFile(filename, data) + if err != nil { + return err + } + } + + // Reload config if it was created. + if filename != "" { + viper.SetConfigType(ext) + if err := viper.ReadConfig(bytes.NewBuffer(configFileContents)); err != nil { + return err + } + if err := viper.Unmarshal(c); err != nil { + return err + } + } + + // Apply. + if c.init.apply { + if err := c.applyArgs(c.destSystem, c.destDirAbsPath, noArgs, applyArgsOptions{ + ignoreEncrypted: c.init.ignoreEncrypted, + include: chezmoi.NewIncludeSet(chezmoi.IncludeAll), + recursive: false, + umask: c.Umask.FileMode(), + preApplyFunc: c.defaultPreApplyFunc, + }); err != nil { + return err + } + } + + // Purge. + if c.init.purge { + if err := c.doPurge(&purgeOptions{ + binary: runtime.GOOS != "windows" && c.init.purgeBinary, + }); err != nil { + return err + } + } + + return nil +} + +// createConfigFile creates a config file using a template and returns its +// contents. +func (c *Config) createConfigFile(filename chezmoi.RelPath, data []byte) ([]byte, error) { + funcMap := make(template.FuncMap) + for key, value := range c.templateFuncs { + funcMap[key] = value + } + for name, f := range map[string]interface{}{ + "promptBool": c.promptBool, + "promptInt": c.promptInt, + "promptString": c.promptString, + } { + funcMap[name] = f + } + + t, err := template.New(string(filename)).Funcs(funcMap).Parse(string(data)) + if err != nil { + return nil, err + } + + sb := strings.Builder{} + if err = t.Execute(&sb, c.defaultTemplateData()); err != nil { + return nil, err + } + contents := []byte(sb.String()) + + configDir := chezmoi.AbsPath(c.bds.ConfigHome).Join("chezmoi") + if err := chezmoi.MkdirAll(c.baseSystem, configDir, 0o777); err != nil { + return nil, err + } + + configPath := configDir.Join(filename) + if err := c.baseSystem.WriteFile(configPath, contents, 0o600); err != nil { + return nil, err + } + + return contents, nil +} + +func (c *Config) findConfigTemplate() (chezmoi.RelPath, string, []byte, error) { + for _, ext := range viper.SupportedExts { + filename := chezmoi.RelPath(chezmoi.Prefix + "." + ext + chezmoi.TemplateSuffix) + contents, err := c.baseSystem.ReadFile(c.sourceDirAbsPath.Join(filename)) + switch { + case os.IsNotExist(err): + continue + case err != nil: + return "", "", nil, err + } + return chezmoi.RelPath("chezmoi." + ext), ext, contents, nil + } + return "", "", nil, nil +} + +func (c *Config) promptBool(field string) bool { + value, err := parseBool(c.promptString(field)) + if err != nil { + returnTemplateError(err) + return false + } + return value +} + +func (c *Config) promptInt(field string) int64 { + value, err := strconv.ParseInt(c.promptString(field), 10, 64) + if err != nil { + returnTemplateError(err) + return 0 + } + return value +} + +func (c *Config) promptString(field string) string { + ttyReader, ttyWriter, err := c.getTTY() + if err != nil { + returnTemplateError(err) + return "" + } + fmt.Fprintf(ttyWriter, "%s? ", field) + value, err := ttyReader.ReadString('\n') + if err != nil { + returnTemplateError(err) + return "" + } + return strings.TrimSpace(value) +} + +// guessDotfilesRepoURL guesses the user's dotfile repo from arg. +func guessDotfilesRepoURL(arg string) string { + for _, dotfileRepoGuess := range dotfilesRepoGuesses { + if dotfileRepoGuess.rx.MatchString(arg) { + return fmt.Sprintf(dotfileRepoGuess.format, arg) + } + } + return arg +} diff --git a/chezmoi2/cmd/initcmd_test.go b/chezmoi2/cmd/initcmd_test.go new file mode 100644 index 000000000000..f0a38b516e92 --- /dev/null +++ b/chezmoi2/cmd/initcmd_test.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGuessDotfilesRepoURL(t *testing.T) { + for argStr, expected := range map[string]string{ + "git@github.com:user/dotfiles.git": "git@github.com:user/dotfiles.git", + "gitlab.com/user": "https://gitlab.com/user/dotfiles.git", + "gitlab.com/user/dots": "https://gitlab.com/user/dots.git", + "gitlab.com/user/dots.git": "https://gitlab.com/user/dots.git", + "https://gitlab.com/user/dots.git": "https://gitlab.com/user/dots.git", + "sr.ht/~user": "https://git.sr.ht/~user/dotfiles", + "sr.ht/~user/dots": "https://git.sr.ht/~user/dots", + "user": "https://github.com/user/dotfiles.git", + "user/dots": "https://github.com/user/dots.git", + "user/dots.git": "https://github.com/user/dots.git", + } { + assert.Equal(t, expected, guessDotfilesRepoURL(argStr)) + } +} diff --git a/chezmoi2/cmd/keepassxctemplatefuncs.go b/chezmoi2/cmd/keepassxctemplatefuncs.go new file mode 100644 index 000000000000..0036da0b2688 --- /dev/null +++ b/chezmoi2/cmd/keepassxctemplatefuncs.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/coreos/go-semver/semver" + "golang.org/x/term" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type keepassxcAttributeCacheKey struct { + entry string + attribute string +} + +type keepassxcConfig struct { + Command string + Database string + Args []string + version *semver.Version + cache map[string]map[string]string + attributeCache map[keepassxcAttributeCacheKey]string + password string +} + +var ( + keepassxcPairRx = regexp.MustCompile(`^([^:]+):\s*(.*)$`) + keepassxcNeedShowProtectedArgVersion = semver.Version{Major: 2, Minor: 5, Patch: 1} +) + +func (c *Config) keepassxcAttributeTemplateFunc(entry, attribute string) string { + key := keepassxcAttributeCacheKey{ + entry: entry, + attribute: attribute, + } + if data, ok := c.Keepassxc.attributeCache[key]; ok { + return data + } + if c.Keepassxc.Database == "" { + returnTemplateError(errors.New("keepassxc.database not set")) + return "" + } + name := c.Keepassxc.Command + args := []string{"show", "--attributes", attribute, "--quiet"} + if c.keepassxcVersion().Compare(keepassxcNeedShowProtectedArgVersion) >= 0 { + args = append(args, "--show-protected") + } + args = append(args, c.Keepassxc.Args...) + args = append(args, c.Keepassxc.Database, entry) + output, err := c.runKeepassxcCLICommand(name, args) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w", name, chezmoi.ShellQuoteArgs(args), err)) + return "" + } + outputStr := strings.TrimSpace(string(output)) + if c.Keepassxc.attributeCache == nil { + c.Keepassxc.attributeCache = make(map[keepassxcAttributeCacheKey]string) + } + c.Keepassxc.attributeCache[key] = outputStr + return outputStr +} + +func (c *Config) keepassxcTemplateFunc(entry string) map[string]string { + if data, ok := c.Keepassxc.cache[entry]; ok { + return data + } + if c.Keepassxc.Database == "" { + returnTemplateError(errors.New("keepassxc.database not set")) + return nil + } + name := c.Keepassxc.Command + args := []string{"show"} + if c.keepassxcVersion().Compare(keepassxcNeedShowProtectedArgVersion) >= 0 { + args = append(args, "--show-protected") + } + args = append(args, c.Keepassxc.Args...) + args = append(args, c.Keepassxc.Database, entry) + output, err := c.runKeepassxcCLICommand(name, args) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w", name, chezmoi.ShellQuoteArgs(args), err)) + return nil + } + data, err := parseKeyPassXCOutput(output) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w", name, chezmoi.ShellQuoteArgs(args), err)) + return nil + } + if c.Keepassxc.cache == nil { + c.Keepassxc.cache = make(map[string]map[string]string) + } + c.Keepassxc.cache[entry] = data + return data +} + +func (c *Config) keepassxcVersion() *semver.Version { + if c.Keepassxc.version != nil { + return c.Keepassxc.version + } + name := c.Keepassxc.Command + args := []string{"--version"} + cmd := exec.Command(name, args...) + output, err := c.baseSystem.IdempotentCmdOutput(cmd) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w", name, chezmoi.ShellQuoteArgs(args), err)) + return nil + } + c.Keepassxc.version, err = semver.NewVersion(string(bytes.TrimSpace(output))) + if err != nil { + returnTemplateError(fmt.Errorf("cannot parse version %s: %w", output, err)) + return nil + } + return c.Keepassxc.version +} + +func (c *Config) runKeepassxcCLICommand(name string, args []string) ([]byte, error) { + if c.Keepassxc.password == "" { + password, err := readPassword(fmt.Sprintf("Insert password to unlock %s: ", c.Keepassxc.Database)) + fmt.Println() + if err != nil { + return nil, err + } + c.Keepassxc.password = string(password) + } + cmd := exec.Command(name, args...) + cmd.Stdin = bytes.NewBufferString(c.Keepassxc.password + "\n") + cmd.Stderr = c.stderr + return c.baseSystem.IdempotentCmdOutput(cmd) +} + +func parseKeyPassXCOutput(output []byte) (map[string]string, error) { + data := make(map[string]string) + s := bufio.NewScanner(bytes.NewReader(output)) + for i := 0; s.Scan(); i++ { + if i == 0 { + continue + } + match := keepassxcPairRx.FindStringSubmatch(s.Text()) + if match == nil { + return nil, fmt.Errorf("%s: parse error", s.Text()) + } + data[match[1]] = match[2] + } + return data, s.Err() +} + +func readPassword(prompt string) (pw []byte, err error) { + fd := int(os.Stdin.Fd()) + if term.IsTerminal(fd) { + fmt.Print(prompt) + pw, err = term.ReadPassword(fd) + fmt.Println() + return + } + + var b [1]byte + for { + n, err := os.Stdin.Read(b[:]) + // term.ReadPassword discards any '\r', so do the same. + if n > 0 && b[0] != '\r' { + if b[0] == '\n' { + return pw, nil + } + pw = append(pw, b[0]) + // Limit size, so that a wrong input won't fill up the memory. + if len(pw) > 1024 { + err = errors.New("password too long") + } + } + if err != nil { + // term.ReadPassword accepts EOF-terminated passwords if non-empty, + // so do the same. + if errors.Is(err, io.EOF) && len(pw) > 0 { + err = nil + } + return pw, err + } + } +} diff --git a/chezmoi2/cmd/keyringtemplatefuncs.go b/chezmoi2/cmd/keyringtemplatefuncs.go new file mode 100644 index 000000000000..e36c1987aa7c --- /dev/null +++ b/chezmoi2/cmd/keyringtemplatefuncs.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "fmt" + + "github.com/zalando/go-keyring" +) + +type keyringKey struct { + service string + user string +} + +type keyringData struct { + cache map[keyringKey]string +} + +func (c *Config) keyringTemplateFunc(service, user string) string { + key := keyringKey{ + service: service, + user: user, + } + if password, ok := c.keyring.cache[key]; ok { + return password + } + password, err := keyring.Get(service, user) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w", service, user, err)) + return "" + } + c.keyring.cache[key] = password + return password +} diff --git a/chezmoi2/cmd/lastpasstemplatefuncs.go b/chezmoi2/cmd/lastpasstemplatefuncs.go new file mode 100644 index 000000000000..20ee6011f29a --- /dev/null +++ b/chezmoi2/cmd/lastpasstemplatefuncs.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os/exec" + "regexp" + "strings" + "unicode" + + "github.com/coreos/go-semver/semver" +) + +var ( + // chezmoi uses lpass show --json which was added in + // https://github.com/lastpass/lastpass-cli/commit/e5a22e2eeef31ab6c54595616e0f57ca0a1c162d + // and the first tag containing that commit is v1.3.0~6. + lastpassMinVersion = semver.Version{Major: 1, Minor: 3, Patch: 0} + lastpassParseNoteRx = regexp.MustCompile(`\A([ A-Za-z]*):(.*)\z`) + lastpassVersionArgs = []string{"--version"} + lastpassVersionRx = regexp.MustCompile(`^LastPass CLI v(\d+\.\d+\.\d+)`) +) + +type lastpassConfig struct { + Command string + versionOK bool + cache map[string][]map[string]interface{} +} + +func (c *Config) lastpassOutput(args ...string) ([]byte, error) { + name := c.Lastpass.Command + cmd := exec.Command(name, args...) + cmd.Stdin = c.stdin + cmd.Stderr = c.stderr + output, err := c.baseSystem.IdempotentCmdOutput(cmd) + if err != nil { + return nil, err + } + return output, nil +} + +func (c *Config) lastpassRawTemplateFunc(id string) []map[string]interface{} { + if !c.Lastpass.versionOK { + if err := c.lastpassVersionCheck(); err != nil { + returnTemplateError(err) + return nil + } + c.Lastpass.versionOK = true + } + + if data, ok := c.Lastpass.cache[id]; ok { + return data + } + + output, err := c.lastpassOutput("show", "--json", id) + if err != nil { + returnTemplateError(err) + return nil + } + + var data []map[string]interface{} + if err := json.Unmarshal(output, &data); err != nil { + returnTemplateError(fmt.Errorf("%s: parse error: %w", output, err)) + return nil + } + + if c.Lastpass.cache == nil { + c.Lastpass.cache = make(map[string][]map[string]interface{}) + } + c.Lastpass.cache[id] = data + return data +} + +func (c *Config) lastpassTemplateFunc(id string) []map[string]interface{} { + data := c.lastpassRawTemplateFunc(id) + for _, d := range data { + if note, ok := d["note"].(string); ok { + d["note"] = lastpassParseNote(note) + } + } + return data +} + +func (c *Config) lastpassVersionCheck() error { + output, err := c.lastpassOutput("--version") + if err != nil { + return err + } + m := lastpassVersionRx.FindSubmatch(output) + if m == nil { + return fmt.Errorf("%s: could not extract version", output) + } + version, err := semver.NewVersion(string(m[1])) + if err != nil { + return err + } + if version.LessThan(lastpassMinVersion) { + return fmt.Errorf("version %s found, need version %s or later", version, lastpassMinVersion) + } + return nil +} + +func lastpassParseNote(note string) map[string]string { + result := make(map[string]string) + s := bufio.NewScanner(bytes.NewBufferString(note)) + key := "" + for s.Scan() { + if m := lastpassParseNoteRx.FindStringSubmatch(s.Text()); m != nil { + keyComponents := strings.Split(m[1], " ") + firstComponentRunes := []rune(keyComponents[0]) + firstComponentRunes[0] = unicode.ToLower(firstComponentRunes[0]) + keyComponents[0] = string(firstComponentRunes) + key = strings.Join(keyComponents, "") + result[key] = m[2] + "\n" + } else { + result[key] += s.Text() + "\n" + } + } + if err := s.Err(); err != nil { + returnTemplateError(err) + return nil + } + return result +} diff --git a/chezmoi2/cmd/lastpasstemplatefuncs_test.go b/chezmoi2/cmd/lastpasstemplatefuncs_test.go new file mode 100644 index 000000000000..edd004aa9af6 --- /dev/null +++ b/chezmoi2/cmd/lastpasstemplatefuncs_test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func Test_lastpassParseNote(t *testing.T) { + for _, tc := range []struct { + note string + expected map[string]string + }{ + { + note: "Foo:bar\n", + expected: map[string]string{ + "foo": "bar\n", + }, + }, + { + note: "Foo:bar\nbaz\n", + expected: map[string]string{ + "foo": "bar\nbaz\n", + }, + }, + { + note: chezmoitest.JoinLines( + "NoteType:SSH Key", + "Language:en-US", + "Bit Strength:2048", + "Format:RSA", + "Passphrase:Passphrase", + "Private Key:-----BEGIN OPENSSH PRIVATE KEY-----", + "-----END OPENSSH PRIVATE KEY-----", + "Public Key:ssh-rsa public-key you@example", + "Hostname:Hostname", + "Date:Date", + ) + "Notes:", + expected: map[string]string{ + "noteType": "SSH Key\n", + "language": "en-US\n", + "bitStrength": "2048\n", + "format": "RSA\n", + "passphrase": "Passphrase\n", + "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\n-----END OPENSSH PRIVATE KEY-----\n", + "publicKey": "ssh-rsa public-key you@example\n", + "hostname": "Hostname\n", + "date": "Date\n", + "notes": "\n", + }, + }, + } { + assert.Equal(t, tc.expected, lastpassParseNote(tc.note)) + } +} diff --git a/chezmoi2/cmd/managedcmd.go b/chezmoi2/cmd/managedcmd.go new file mode 100644 index 000000000000..da132b15814b --- /dev/null +++ b/chezmoi2/cmd/managedcmd.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type managedCmdConfig struct { + include *chezmoi.IncludeSet +} + +func (c *Config) newManagedCmd() *cobra.Command { + managedCmd := &cobra.Command{ + Use: "managed", + Short: "List the managed entries in the destination directory", + Long: mustLongHelp("managed"), + Example: example("managed"), + Args: cobra.NoArgs, + RunE: c.makeRunEWithSourceState(c.runManagedCmd), + } + + flags := managedCmd.Flags() + flags.VarP(c.managed.include, "include", "i", "include entry types") + + return managedCmd +} + +func (c *Config) runManagedCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + entries := sourceState.Entries() + targetRelPaths := make(chezmoi.RelPaths, 0, len(entries)) + for targetRelPath, sourceStateEntry := range entries { + targetStateEntry, err := sourceStateEntry.TargetStateEntry() + if err != nil { + return err + } + if !c.managed.include.IncludeTargetStateEntry(targetStateEntry) { + continue + } + targetRelPaths = append(targetRelPaths, targetRelPath) + } + + sort.Sort(targetRelPaths) + sb := strings.Builder{} + for _, targetRelPath := range targetRelPaths { + fmt.Fprintln(&sb, targetRelPath) + } + return c.writeOutputString(sb.String()) +} diff --git a/chezmoi2/cmd/mergecmd.go b/chezmoi2/cmd/mergecmd.go new file mode 100644 index 000000000000..dd6253532fa8 --- /dev/null +++ b/chezmoi2/cmd/mergecmd.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "fmt" + "io/ioutil" + "os" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type mergeCmdConfig struct { + Command string + Args []string +} + +func (c *Config) newMergeCmd() *cobra.Command { + mergeCmd := &cobra.Command{ + Use: "merge target...", + Args: cobra.MinimumNArgs(1), + Short: "Perform a three-way merge between the destination state, the source state, and the target state", + Long: mustLongHelp("merge"), + Example: example("merge"), + RunE: c.makeRunEWithSourceState(c.runMergeCmd), + Annotations: map[string]string{ + modifiesSourceDirectory: "true", + requiresSourceDirectory: "true", + }, + } + + return mergeCmd +} + +func (c *Config) runMergeCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ + mustBeInSourceState: false, + recursive: true, + }) + if err != nil { + return err + } + + // Create a temporary directory to store the target state and ensure that it + // is removed afterwards. We cannot use fs as it lacks TempDir + // functionality. + tempDir, err := ioutil.TempDir("", "chezmoi") + if err != nil { + return err + } + defer os.RemoveAll(tempDir) + tempDirAbsPath := chezmoi.AbsPath(tempDir) + + for _, targetRelPath := range targetRelPaths { + sourceStateEntry := sourceState.MustEntry(targetRelPath) + // FIXME sourceStateEntry.TargetStateEntry eagerly evaluates the return + // targetStateEntry's contents, which means that we cannot fallback to a + // two-way merge if the source state's contents cannot be decrypted or + // are an invalid template + targetStateEntry, err := sourceStateEntry.TargetStateEntry() + if err != nil { + return fmt.Errorf("%s: %w", targetRelPath, err) + } + targetStateFile, ok := targetStateEntry.(*chezmoi.TargetStateFile) + if !ok { + // LATER consider handling symlinks? + return fmt.Errorf("%s: not a file", targetRelPath) + } + contents, err := targetStateFile.Contents() + if err != nil { + return err + } + targetStatePath := tempDirAbsPath.Join(chezmoi.RelPath(targetRelPath.Base())) + if err := c.baseSystem.WriteFile(targetStatePath, contents, 0o600); err != nil { + return err + } + args := append( + append([]string{}, c.Merge.Args...), + string(c.destDirAbsPath.Join(targetRelPath)), + string(c.sourceDirAbsPath.Join(sourceStateEntry.SourceRelPath().RelPath())), + string(targetStatePath), + ) + if err := c.run(c.destDirAbsPath, c.Merge.Command, args); err != nil { + return fmt.Errorf("%s: %w", targetRelPath, err) + } + } + + return nil +} diff --git a/chezmoi2/cmd/onepasswordtemplatefuncs.go b/chezmoi2/cmd/onepasswordtemplatefuncs.go new file mode 100644 index 000000000000..70ef7b1f3eeb --- /dev/null +++ b/chezmoi2/cmd/onepasswordtemplatefuncs.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type onepasswordConfig struct { + Command string + outputCache map[string][]byte +} + +func (c *Config) onepasswordDetailsFieldsTemplateFunc(args ...string) map[string]interface{} { + key, vault := onepasswordGetKeyAndVault(args) + onepasswordArgs := []string{"get", "item", key} + if vault != "" { + onepasswordArgs = append(onepasswordArgs, "--vault", vault) + } + output := c.onepasswordOutput(onepasswordArgs) + var data struct { + Details struct { + Fields []map[string]interface{} `json:"fields"` + } `json:"details"` + } + if err := json.Unmarshal(output, &data); err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w\n%s", c.Onepassword.Command, chezmoi.ShellQuoteArgs(onepasswordArgs), err, output)) + return nil + } + result := make(map[string]interface{}) + for _, field := range data.Details.Fields { + if designation, ok := field["designation"].(string); ok { + result[designation] = field + } + } + return result +} + +func (c *Config) onepasswordDocumentTemplateFunc(args ...string) string { + key, vault := onepasswordGetKeyAndVault(args) + onepasswordArgs := []string{"get", "document", key} + if vault != "" { + onepasswordArgs = append(onepasswordArgs, "--vault", vault) + } + output := c.onepasswordOutput(onepasswordArgs) + return string(output) +} + +func (c *Config) onepasswordOutput(args []string) []byte { + key := strings.Join(args, "\x00") + if output, ok := c.Onepassword.outputCache[key]; ok { + return output + } + + name := c.Onepassword.Command + cmd := exec.Command(name, args...) + cmd.Stdin = c.stdin + cmd.Stderr = c.stderr + output, err := c.baseSystem.IdempotentCmdOutput(cmd) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w\n%s", name, chezmoi.ShellQuoteArgs(args), err, output)) + return nil + } + + if c.Onepassword.outputCache == nil { + c.Onepassword.outputCache = make(map[string][]byte) + } + c.Onepassword.outputCache[key] = output + return output +} + +func (c *Config) onepasswordTemplateFunc(args ...string) map[string]interface{} { + key, vault := onepasswordGetKeyAndVault(args) + onepasswordArgs := []string{"get", "item", key} + if vault != "" { + onepasswordArgs = append(onepasswordArgs, "--vault", vault) + } + output := c.onepasswordOutput(onepasswordArgs) + var data map[string]interface{} + if err := json.Unmarshal(output, &data); err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w\n%s", c.Onepassword.Command, chezmoi.ShellQuoteArgs(onepasswordArgs), err, output)) + return nil + } + return data +} + +func onepasswordGetKeyAndVault(args []string) (string, string) { + switch len(args) { + case 1: + return args[0], "" + case 2: + return args[0], args[1] + default: + returnTemplateError(fmt.Errorf("expected 1 or 2 arguments, got %d", len(args))) + return "", "" + } +} diff --git a/chezmoi2/cmd/passtemplatefuncs.go b/chezmoi2/cmd/passtemplatefuncs.go new file mode 100644 index 000000000000..93f68e5eafdf --- /dev/null +++ b/chezmoi2/cmd/passtemplatefuncs.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "bytes" + "fmt" + "os/exec" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type passConfig struct { + Command string + cache map[string]string +} + +func (c *Config) passTemplateFunc(id string) string { + if s, ok := c.Pass.cache[id]; ok { + return s + } + name := c.Pass.Command + args := []string{"show", id} + cmd := exec.Command(name, args...) + cmd.Stdin = c.stdin + cmd.Stderr = c.stderr + output, err := c.baseSystem.IdempotentCmdOutput(cmd) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w", name, chezmoi.ShellQuoteArgs(args), err)) + return "" + } + var password string + if index := bytes.IndexByte(output, '\n'); index != -1 { + password = string(output[:index]) + } else { + password = string(output) + } + if c.Pass.cache == nil { + c.Pass.cache = make(map[string]string) + } + c.Pass.cache[id] = password + return password +} diff --git a/chezmoi2/cmd/purgecmd.go b/chezmoi2/cmd/purgecmd.go new file mode 100644 index 000000000000..d04b7224cd71 --- /dev/null +++ b/chezmoi2/cmd/purgecmd.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +type purgeCmdConfig struct { + binary bool +} + +func (c *Config) newPurgeCmd() *cobra.Command { + purgeCmd := &cobra.Command{ + Use: "purge", + Short: "Purge chezmoi's configuration and data", + Long: mustLongHelp("purge"), + Example: example("purge"), + Args: cobra.NoArgs, + RunE: c.runPurgeCmd, + Annotations: map[string]string{ + modifiesSourceDirectory: "true", + }, + } + + flags := purgeCmd.Flags() + flags.BoolVarP(&c.purge.binary, "binary", "P", c.purge.binary, "purge chezmoi executable") + + return purgeCmd +} + +func (c *Config) runPurgeCmd(cmd *cobra.Command, args []string) error { + return c.doPurge(&purgeOptions{ + binary: c.purge.binary, + }) +} diff --git a/chezmoi2/cmd/removecmd.go b/chezmoi2/cmd/removecmd.go new file mode 100644 index 000000000000..074b95c3f6b9 --- /dev/null +++ b/chezmoi2/cmd/removecmd.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +func (c *Config) newRemoveCmd() *cobra.Command { + removeCmd := &cobra.Command{ + Use: "remove target...", + Aliases: []string{"rm"}, + Short: "Remove a target from the source state and the destination directory", + Long: mustLongHelp("remove"), + Example: example("remove"), + Args: cobra.MinimumNArgs(1), + RunE: c.makeRunEWithSourceState(c.runRemoveCmd), + Annotations: map[string]string{ + modifiesDestinationDirectory: "true", + modifiesSourceDirectory: "true", + }, + } + + return removeCmd +} + +func (c *Config) runRemoveCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ + mustBeInSourceState: true, + }) + if err != nil { + return err + } + + for _, targetRelPath := range targetRelPaths { + destAbsPath := c.destDirAbsPath.Join(targetRelPath) + sourceAbsPath := c.sourceDirAbsPath.Join(sourceState.MustEntry(targetRelPath).SourceRelPath().RelPath()) + if !c.force { + choice, err := c.prompt(fmt.Sprintf("Remove %s and %s", destAbsPath, sourceAbsPath), "ynqa") + if err != nil { + return err + } + switch choice { + case 'y': + case 'n': + continue + case 'q': + return nil + case 'a': + c.force = true + } + } + if err := c.destSystem.RemoveAll(destAbsPath); err != nil && !os.IsNotExist(err) { + return err + } + if err := c.sourceSystem.RemoveAll(sourceAbsPath); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} diff --git a/chezmoi2/cmd/secrettemplatefuncs.go b/chezmoi2/cmd/secrettemplatefuncs.go new file mode 100644 index 000000000000..0edb04516cc2 --- /dev/null +++ b/chezmoi2/cmd/secrettemplatefuncs.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type secretConfig struct { + Command string + cache map[string]string + jsonCache map[string]interface{} +} + +func (c *Config) secretJSONTemplateFunc(args ...string) interface{} { + key := strings.Join(args, "\x00") + if value, ok := c.Secret.jsonCache[key]; ok { + return value + } + name := c.Secret.Command + cmd := exec.Command(name, args...) + cmd.Stdin = c.stdin + cmd.Stderr = c.stderr + output, err := c.baseSystem.IdempotentCmdOutput(cmd) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w\n%s", name, chezmoi.ShellQuoteArgs(args), err, output)) + return nil + } + var value interface{} + if err := json.Unmarshal(output, &value); err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w\n%s", name, chezmoi.ShellQuoteArgs(args), err, output)) + return nil + } + if c.Secret.jsonCache == nil { + c.Secret.jsonCache = make(map[string]interface{}) + } + c.Secret.jsonCache[key] = value + return value +} + +func (c *Config) secretTemplateFunc(args ...string) string { + key := strings.Join(args, "\x00") + if value, ok := c.Secret.cache[key]; ok { + return value + } + name := c.Secret.Command + cmd := exec.Command(name, args...) + cmd.Stdin = c.stdin + cmd.Stderr = c.stderr + output, err := c.baseSystem.IdempotentCmdOutput(cmd) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w\n%s", name, chezmoi.ShellQuoteArgs(args), err, output)) + return "" + } + value := string(bytes.TrimSpace(output)) + if c.Secret.cache == nil { + c.Secret.cache = make(map[string]string) + } + c.Secret.cache[key] = value + return value +} diff --git a/chezmoi2/cmd/sourcepathcmd.go b/chezmoi2/cmd/sourcepathcmd.go new file mode 100644 index 000000000000..f8d3c89e5d05 --- /dev/null +++ b/chezmoi2/cmd/sourcepathcmd.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +func (c *Config) newSourcePathCmd() *cobra.Command { + sourcePathCmd := &cobra.Command{ + Use: "source-path [target]...", + Short: "Print the path of a target in the source state", + Long: mustLongHelp("source-path"), + Example: example("source-path"), + RunE: c.makeRunEWithSourceState(c.runSourcePathCmd), + } + + return sourcePathCmd +} + +func (c *Config) runSourcePathCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + if len(args) == 0 { + return c.writeOutputString(string(c.sourceDirAbsPath) + "\n") + } + + sourceAbsPaths, err := c.sourceAbsPaths(sourceState, args) + if err != nil { + return err + } + + sb := strings.Builder{} + for _, sourceAbsPath := range sourceAbsPaths { + fmt.Fprintln(&sb, sourceAbsPath) + } + return c.writeOutputString(sb.String()) +} diff --git a/chezmoi2/cmd/statecmd.go b/chezmoi2/cmd/statecmd.go new file mode 100644 index 000000000000..481c12648b49 --- /dev/null +++ b/chezmoi2/cmd/statecmd.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +func (c *Config) newStateCmd() *cobra.Command { + stateCmd := &cobra.Command{ + Use: "state", + Short: "Manipulate the persistent state", + } + + dumpCmd := &cobra.Command{ + Use: "dump", + Short: "Generate a dump of the persistent state", + // Long: mustLongHelp("state", "dump"), // FIXME + // Example: example("state", "dump"), // FIXME + Args: cobra.NoArgs, + RunE: c.runStateDataCmd, + Annotations: map[string]string{ + persistentStateMode: persistentStateModeReadOnly, + }, + } + stateCmd.AddCommand(dumpCmd) + + resetCmd := &cobra.Command{ + Use: "reset", + Short: "Reset the persistent state", + // Long: mustLongHelp("state", "reset"), // FIXME + // Example: example("state", "reset"), // FIXME + Args: cobra.NoArgs, + RunE: c.runStateResetCmd, + } + stateCmd.AddCommand(resetCmd) + + return stateCmd +} + +func (c *Config) runStateDataCmd(cmd *cobra.Command, args []string) error { + data, err := chezmoi.PersistentStateData(c.persistentState) + if err != nil { + return err + } + return c.marshal(data) +} + +func (c *Config) runStateResetCmd(cmd *cobra.Command, args []string) error { + path := c.persistentStateFile() + _, err := c.baseSystem.Stat(path) + if os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + if !c.force { + choice, err := c.prompt(fmt.Sprintf("Remove %s", path), "yn") + if err != nil { + return err + } + if choice == 'n' { + return nil + } + } + return c.baseSystem.RemoveAll(path) +} diff --git a/chezmoi2/cmd/statuscmd.go b/chezmoi2/cmd/statuscmd.go new file mode 100644 index 000000000000..dc8310904ed6 --- /dev/null +++ b/chezmoi2/cmd/statuscmd.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type statusCmdConfig struct { + include *chezmoi.IncludeSet + recursive bool +} + +func (c *Config) newStatusCmd() *cobra.Command { + statusCmd := &cobra.Command{ + Use: "status [target]...", + Short: "Show the status of targets", + // Long: mustGetLongHelp("status"), // FIXME + Example: example("status"), + RunE: c.makeRunEWithSourceState(c.runStatusCmd), + Annotations: map[string]string{ + modifiesDestinationDirectory: "true", + persistentStateMode: persistentStateModeReadMockWrite, + }, + } + + flags := statusCmd.Flags() + flags.VarP(c.status.include, "include", "i", "include entry types") + flags.BoolVarP(&c.status.recursive, "recursive", "r", c.status.recursive, "recursive") + + return statusCmd +} + +func (c *Config) runStatusCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + sb := strings.Builder{} + dryRunSystem := chezmoi.NewDryRunSystem(c.destSystem) + preApplyFunc := func(targetRelPath chezmoi.RelPath, targetEntryState, lastWrittenEntryState, actualEntryState *chezmoi.EntryState) error { + var ( + x = ' ' + y = ' ' + ) + switch { + case targetEntryState.Type == chezmoi.EntryStateTypeScript: + y = 'R' + case !targetEntryState.Equivalent(actualEntryState, c.Umask.FileMode()): + x = statusRune(lastWrittenEntryState, actualEntryState, c.Umask.FileMode()) + y = statusRune(actualEntryState, targetEntryState, c.Umask.FileMode()) + } + if x != ' ' || y != ' ' { + fmt.Fprintf(&sb, "%c%c %s\n", x, y, targetRelPath) + } + return chezmoi.Skip + } + if err := c.applyArgs(dryRunSystem, c.destDirAbsPath, args, applyArgsOptions{ + include: c.status.include, + recursive: c.status.recursive, + umask: c.Umask.FileMode(), + preApplyFunc: preApplyFunc, + }); err != nil { + return err + } + return c.writeOutputString(sb.String()) +} + +func statusRune(fromState, toState *chezmoi.EntryState, umask os.FileMode) rune { + if fromState == nil || fromState.Equivalent(toState, umask) { + return ' ' + } + switch toState.Type { + case chezmoi.EntryStateTypeAbsent: + return 'D' + case chezmoi.EntryStateTypeDir, chezmoi.EntryStateTypeFile, chezmoi.EntryStateTypeSymlink: + //nolint:exhaustive + switch fromState.Type { + case chezmoi.EntryStateTypeAbsent: + return 'A' + default: + return 'M' + } + case chezmoi.EntryStateTypePresent: + return 'A' + case chezmoi.EntryStateTypeScript: + return 'R' + default: + return '?' + } +} diff --git a/chezmoi2/cmd/statuscmd_test.go b/chezmoi2/cmd/statuscmd_test.go new file mode 100644 index 000000000000..eca249df777c --- /dev/null +++ b/chezmoi2/cmd/statuscmd_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestStatusCmd(t *testing.T) { + for _, tc := range []struct { + name string + root interface{} + args []string + postApplyTests []interface{} + stdoutStr string + }{ + { + name: "add_file", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi/dot_bashrc": "# contents of .bashrc\n", + }, + args: []string{"~/.bashrc"}, + stdoutStr: chezmoitest.JoinLines( + ` A .bashrc`, + ), + postApplyTests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_bashrc", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^chezmoi.GetUmask()), + vfst.TestContentsString("# contents of .bashrc\n"), + ), + }, + }, + { + name: "update_symlink", + root: map[string]interface{}{ + "/home/user/.symlink": &vfst.Symlink{Target: "old-target"}, + "/home/user/.local/share/chezmoi/symlink_dot_symlink": "new-target\n", + }, + args: []string{"~/.symlink"}, + postApplyTests: []interface{}{ + vfst.TestPath("/home/user/.symlink", + vfst.TestSymlinkTarget("new-target"), + ), + }, + stdoutStr: chezmoitest.JoinLines( + ` M .symlink`, + ), + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fs vfs.FS) { + var stdout strings.Builder + require.NoError(t, newTestConfig(t, fs, withStdout(&stdout)).execute(append([]string{"status"}, tc.args...))) + assert.Equal(t, tc.stdoutStr, stdout.String()) + + require.NoError(t, newTestConfig(t, fs).execute(append([]string{"apply"}, tc.args...))) + vfst.RunTests(t, fs, "", tc.postApplyTests...) + + stdout.Reset() + require.NoError(t, newTestConfig(t, fs, withStdout(&stdout)).execute(append([]string{"status"}, tc.args...))) + assert.Empty(t, stdout.String()) + }) + }) + } +} diff --git a/chezmoi2/cmd/templatefuncs.go b/chezmoi2/cmd/templatefuncs.go new file mode 100644 index 000000000000..074ac7d27f55 --- /dev/null +++ b/chezmoi2/cmd/templatefuncs.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "howett.net/plist" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type ioregData struct { + value map[string]interface{} +} + +func (c *Config) includeTemplateFunc(filename string) string { + contents, err := c.fs.ReadFile(string(c.sourceDirAbsPath.Join(chezmoi.RelPath(filename)))) + if err != nil { + returnTemplateError(err) + return "" + } + return string(contents) +} + +func (c *Config) ioregTemplateFunc() map[string]interface{} { + if runtime.GOOS != "darwin" { + return nil + } + + if c.ioregData.value != nil { + return c.ioregData.value + } + + cmd := exec.Command("ioreg", "-a", "-l") + output, err := c.baseSystem.IdempotentCmdOutput(cmd) + if err != nil { + returnTemplateError(fmt.Errorf("ioreg: %w", err)) + return nil + } + + var value map[string]interface{} + if _, err := plist.Unmarshal(output, &value); err != nil { + returnTemplateError(fmt.Errorf("ioreg: %w", err)) + return nil + } + c.ioregData.value = value + return value +} + +func (c *Config) joinPathTemplateFunc(elem ...string) string { + return filepath.Join(elem...) +} + +func (c *Config) lookPathTemplateFunc(file string) string { + path, err := exec.LookPath(file) + switch { + case err == nil: + return path + case errors.Is(err, exec.ErrNotFound): + return "" + default: + returnTemplateError(err) + return "" + } +} + +func (c *Config) statTemplateFunc(name string) interface{} { + info, err := c.fs.Stat(name) + switch { + case err == nil: + return map[string]interface{}{ + "name": info.Name(), + "size": info.Size(), + "mode": int(info.Mode()), + "perm": int(info.Mode() & os.ModePerm), + "modTime": info.ModTime().Unix(), + "isDir": info.IsDir(), + } + case os.IsNotExist(err): + return nil + default: + returnTemplateError(err) + return nil + } +} + +func returnTemplateError(err error) { + panic(err) +} diff --git a/chezmoi2/cmd/templates.gen.go b/chezmoi2/cmd/templates.gen.go new file mode 100644 index 000000000000..f331042e250c --- /dev/null +++ b/chezmoi2/cmd/templates.gen.go @@ -0,0 +1,31 @@ +// Code generated by github.com/twpayne/chezmoi/internal/cmd/generate-assets. DO NOT EDIT. + +package cmd + +func init() { + assets["assets/templates/COMMIT_MESSAGE.tmpl"] = []byte("" + + "{{- /* FIXME generate commit summary */ -}}\n" + + "\n" + + "{{- range .Ordinary -}}\n" + + "{{ if and (eq .X 'A') (eq .Y '.') -}}Add {{ .Path }}\n" + + "{{ else if and (eq .X 'D') (eq .Y '.') -}}Remove {{ .Path }}\n" + + "{{ else if and (eq .X 'M') (eq .Y '.') -}}Update {{ .Path }}\n" + + "{{ else }}{{with (printf \"unsupported XY: %q\" (printf \"%c%c\" .X .Y)) }}{{ fail . }}{{ end }}\n" + + "{{ end }}\n" + + "{{- end -}}\n" + + "\n" + + "{{- range .RenamedOrCopied -}}\n" + + "{{ if and (eq .X 'R') (eq .Y '.') }}Rename {{ .OrigPath }} to {{ .Path }}\n" + + "{{ else }}{{with (printf \"unsupported XY: %q\" (printf \"%c%c\" .X .Y)) }}{{ fail . }}{{ end }}\n" + + "{{ end }}\n" + + "{{- end -}}\n" + + "\n" + + "{{- range .Unmerged -}}\n" + + "{{ fail \"unmerged files\" }}\n" + + "{{- end -}}\n" + + "\n" + + "{{- range .Untracked -}}\n" + + "{{ fail \"untracked files\" }}\n" + + "{{- end -}}\n" + + "\n") +} diff --git a/chezmoi2/cmd/unmanagedcmd.go b/chezmoi2/cmd/unmanagedcmd.go new file mode 100644 index 000000000000..70893dc9f7a9 --- /dev/null +++ b/chezmoi2/cmd/unmanagedcmd.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "os" + "strings" + + "github.com/spf13/cobra" + vfs "github.com/twpayne/go-vfs" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +func (c *Config) newUnmanagedCmd() *cobra.Command { + unmanagedCmd := &cobra.Command{ + Use: "unmanaged", + Short: "List the unmanaged files in the destination directory", + Long: mustLongHelp("unmanaged"), + Example: example("unmanaged"), + Args: cobra.NoArgs, + RunE: c.makeRunEWithSourceState(c.runUnmanagedCmd), + } + + return unmanagedCmd +} + +func (c *Config) runUnmanagedCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + sb := strings.Builder{} + if err := chezmoi.Walk(c.destSystem, c.destDirAbsPath, func(destAbsPath chezmoi.AbsPath, info os.FileInfo, err error) error { + if err != nil { + return err + } + if destAbsPath == c.destDirAbsPath { + return nil + } + targeRelPath := destAbsPath.MustTrimDirPrefix(c.destDirAbsPath) + _, managed := sourceState.Entry(targeRelPath) + ignored := sourceState.Ignored(targeRelPath) + if !managed && !ignored { + sb.WriteString(string(targeRelPath) + "\n") + } + if info.IsDir() && (!managed || ignored) { + return vfs.SkipDir + } + return nil + }); err != nil { + return err + } + return c.writeOutputString(sb.String()) +} diff --git a/chezmoi2/cmd/updatecmd.go b/chezmoi2/cmd/updatecmd.go new file mode 100644 index 000000000000..774c8aa5e97c --- /dev/null +++ b/chezmoi2/cmd/updatecmd.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "errors" + + "github.com/go-git/go-git/v5" + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type updateCmdConfig struct { + apply bool + include *chezmoi.IncludeSet + recursive bool +} + +func (c *Config) newUpdateCmd() *cobra.Command { + updateCmd := &cobra.Command{ + Use: "update", + Short: "Pull and apply any changes", + Long: mustLongHelp("update"), + Example: example("update"), + Args: cobra.NoArgs, + RunE: c.runUpdateCmd, + Annotations: map[string]string{ + modifiesDestinationDirectory: "true", + persistentStateMode: persistentStateModeReadWrite, + requiresSourceDirectory: "true", + runsCommands: "true", + }, + } + + flags := updateCmd.Flags() + flags.BoolVarP(&c.update.apply, "apply", "a", c.update.apply, "apply after pulling") + flags.VarP(c.update.include, "include", "i", "include entry types") + flags.BoolVarP(&c.update.recursive, "recursive", "r", c.update.recursive, "recursive") + + return updateCmd +} + +func (c *Config) runUpdateCmd(cmd *cobra.Command, args []string) error { + switch useBuiltinGit, err := c.useBuiltinGit(); { + case err != nil: + return err + case useBuiltinGit: + rawSourceAbsPath, err := c.baseSystem.RawPath(c.sourceDirAbsPath) + if err != nil { + return err + } + repo, err := git.PlainOpen(string(rawSourceAbsPath)) + if err != nil { + return err + } + wt, err := repo.Worktree() + if err != nil { + return err + } + if err := wt.Pull(&git.PullOptions{ + RecurseSubmodules: git.DefaultSubmoduleRecursionDepth, + }); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { + return err + } + default: + args := []string{ + "pull", + "--rebase", + "--recurse-submodules", + } + if err := c.run(c.sourceDirAbsPath, c.Git.Command, args); err != nil { + return err + } + } + + if !c.update.apply { + return nil + } + + return c.applyArgs(c.destSystem, c.destDirAbsPath, args, applyArgsOptions{ + include: c.update.include, + recursive: c.update.recursive, + umask: c.Umask.FileMode(), + preApplyFunc: c.defaultPreApplyFunc, + }) +} diff --git a/chezmoi2/cmd/util.go b/chezmoi2/cmd/util.go new file mode 100644 index 000000000000..b577827d0984 --- /dev/null +++ b/chezmoi2/cmd/util.go @@ -0,0 +1,161 @@ +package cmd + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/spf13/viper" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-xdg/v3" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +var wellKnownAbbreviations = map[string]struct{}{ + "ANSI": {}, + "CPE": {}, + "ID": {}, + "URL": {}, +} + +// defaultConfigFile returns the default config file according to the XDG Base +// Directory Specification. +func defaultConfigFile(fs vfs.Stater, bds *xdg.BaseDirectorySpecification) chezmoi.AbsPath { + // Search XDG Base Directory Specification config directories first. + for _, configDir := range bds.ConfigDirs { + configDirAbsPath := chezmoi.AbsPath(configDir) + for _, extension := range viper.SupportedExts { + configFileAbsPath := configDirAbsPath.Join(chezmoi.RelPath("chezmoi"), chezmoi.RelPath("chezmoi."+extension)) + if _, err := fs.Stat(string(configFileAbsPath)); err == nil { + return configFileAbsPath + } + } + } + // Fallback to XDG Base Directory Specification default. + return chezmoi.AbsPath(bds.ConfigHome).Join(chezmoi.RelPath("chezmoi"), chezmoi.RelPath("chezmoi.toml")) +} + +// defaultSourceDir returns the default source directory according to the XDG +// Base Directory Specification. +func defaultSourceDir(fs vfs.Stater, bds *xdg.BaseDirectorySpecification) chezmoi.AbsPath { + // Check for XDG Base Directory Specification data directories first. + for _, dataDir := range bds.DataDirs { + dataDirAbsPath := chezmoi.AbsPath(dataDir) + sourceDirAbsPath := dataDirAbsPath.Join(chezmoi.RelPath("chezmoi")) + if _, err := fs.Stat(string(sourceDirAbsPath)); err == nil { + return sourceDirAbsPath + } + } + // Fallback to XDG Base Directory Specification default. + return chezmoi.AbsPath(bds.DataHome).Join(chezmoi.RelPath("chezmoi")) +} + +// firstNonEmptyString returns its first non-empty argument, or "" if all +// arguments are empty. +func firstNonEmptyString(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" +} + +// isWellKnownAbbreviation returns true if word is a well known abbreviation. +func isWellKnownAbbreviation(word string) bool { + _, ok := wellKnownAbbreviations[word] + return ok +} + +// parseBool is like strconv.ParseBool but also accepts on, ON, y, Y, yes, YES, +// n, N, no, NO, off, and OFF. +func parseBool(str string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(str)) { + case "n", "no", "off": + return false, nil + case "on", "y", "yes": + return true, nil + default: + return strconv.ParseBool(str) + } +} + +// serializationFormatNamesStr returns the list of serialization formats as a +// comma-separated list. +func serializationFormatNamesStr() string { + names := make([]string, 0, len(chezmoi.Formats)) + for name := range chezmoi.Formats { + names = append(names, 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, ", ") + } +} + +// titleize returns s with its first rune titlized. +func titleize(s string) string { + if s == "" { + return s + } + runes := []rune(s) + return string(append([]rune{unicode.ToTitle(runes[0])}, runes[1:]...)) +} + +// upperSnakeCaseToCamelCase converts a string in UPPER_SNAKE_CASE to +// camelCase. +func upperSnakeCaseToCamelCase(s string) string { + words := strings.Split(s, "_") + for i, word := range words { + if i == 0 { + words[i] = strings.ToLower(word) + } else if !isWellKnownAbbreviation(word) { + words[i] = titleize(strings.ToLower(word)) + } + } + return strings.Join(words, "") +} + +// upperSnakeCaseToCamelCaseKeys returns m with all keys converted from +// UPPER_SNAKE_CASE to camelCase. +func upperSnakeCaseToCamelCaseMap(m map[string]string) map[string]string { + result := make(map[string]string) + for k, v := range m { + result[upperSnakeCaseToCamelCase(k)] = v + } + return result +} + +// validateKeys ensures that all keys in data match re. +func validateKeys(data interface{}, re *regexp.Regexp) error { + switch data := data.(type) { + case map[string]interface{}: + for key, value := range data { + if !re.MatchString(key) { + return fmt.Errorf("%s: invalid key", key) + } + if err := validateKeys(value, re); err != nil { + return err + } + } + case []interface{}: + for _, value := range data { + if err := validateKeys(value, re); err != nil { + return err + } + } + } + return nil +} diff --git a/chezmoi2/cmd/util_test.go b/chezmoi2/cmd/util_test.go new file mode 100644 index 000000000000..ed65bfd4f01f --- /dev/null +++ b/chezmoi2/cmd/util_test.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUpperSnakeCaseToCamelCaseMap(t *testing.T) { + actual := upperSnakeCaseToCamelCaseMap(map[string]string{ + "BUG_REPORT_URL": "", + "ID": "", + }) + assert.Equal(t, map[string]string{ + "bugReportURL": "", + "id": "", + }, actual) +} diff --git a/chezmoi2/cmd/util_unix.go b/chezmoi2/cmd/util_unix.go new file mode 100644 index 000000000000..a4619df193a0 --- /dev/null +++ b/chezmoi2/cmd/util_unix.go @@ -0,0 +1,12 @@ +// +build !windows + +package cmd + +import ( + "io" +) + +// enableVirtualTerminalProcessing does nothing. +func enableVirtualTerminalProcessing(w io.Writer) error { + return nil +} diff --git a/chezmoi2/cmd/util_windows.go b/chezmoi2/cmd/util_windows.go new file mode 100644 index 000000000000..dd5b6adceb0d --- /dev/null +++ b/chezmoi2/cmd/util_windows.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "io" + "os" + + "golang.org/x/sys/windows" +) + +// enableVirtualTerminalProcessing enables virtual terminal processing. See +// https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences. +func enableVirtualTerminalProcessing(w io.Writer) error { + f, ok := w.(*os.File) + if !ok { + return nil + } + var dwMode uint32 + if err := windows.GetConsoleMode(windows.Handle(f.Fd()), &dwMode); err != nil { + return nil // Ignore error in the case that fd is not a terminal. + } + return windows.SetConsoleMode(windows.Handle(f.Fd()), dwMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) +} diff --git a/chezmoi2/cmd/vaulttemplatefuncs.go b/chezmoi2/cmd/vaulttemplatefuncs.go new file mode 100644 index 000000000000..edc25aef21e3 --- /dev/null +++ b/chezmoi2/cmd/vaulttemplatefuncs.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os/exec" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type vaultConfig struct { + Command string + cache map[string]interface{} +} + +func (c *Config) vaultTemplateFunc(key string) interface{} { + if data, ok := c.Vault.cache[key]; ok { + return data + } + name := c.Vault.Command + args := []string{"kv", "get", "-format=json", key} + cmd := exec.Command(name, args...) + cmd.Stdin = c.stdin + cmd.Stderr = c.stderr + output, err := c.baseSystem.IdempotentCmdOutput(cmd) + if err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w\n%s", name, chezmoi.ShellQuoteArgs(args), err, output)) + return nil + } + var data interface{} + if err := json.Unmarshal(output, &data); err != nil { + returnTemplateError(fmt.Errorf("%s %s: %w\n%s", name, chezmoi.ShellQuoteArgs(args), err, output)) + return nil + } + if c.Vault.cache == nil { + c.Vault.cache = make(map[string]interface{}) + } + c.Vault.cache[key] = data + return data +} diff --git a/chezmoi2/cmd/verifycmd.go b/chezmoi2/cmd/verifycmd.go new file mode 100644 index 000000000000..4aa2696ff07a --- /dev/null +++ b/chezmoi2/cmd/verifycmd.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" +) + +type verifyCmdConfig struct { + include *chezmoi.IncludeSet + recursive bool +} + +func (c *Config) newVerifyCmd() *cobra.Command { + verifyCmd := &cobra.Command{ + Use: "verify [target]...", + Short: "Exit with success if the destination state matches the target state, fail otherwise", + Long: mustLongHelp("verify"), + Example: example("verify"), + RunE: c.runVerifyCmd, + Annotations: map[string]string{ + persistentStateMode: persistentStateModeReadMockWrite, + }, + } + + flags := verifyCmd.Flags() + flags.VarP(c.verify.include, "include", "i", "include entry types") + flags.BoolVarP(&c.verify.recursive, "recursive", "r", c.verify.recursive, "recursive") + + return verifyCmd +} + +func (c *Config) runVerifyCmd(cmd *cobra.Command, args []string) error { + dryRunSystem := chezmoi.NewDryRunSystem(c.destSystem) + if err := c.applyArgs(dryRunSystem, c.destDirAbsPath, args, applyArgsOptions{ + include: c.verify.include, + recursive: c.verify.recursive, + umask: c.Umask.FileMode(), + }); err != nil { + return err + } + if dryRunSystem.Modified() { + return ErrExitCode(1) + } + return nil +} diff --git a/chezmoi2/cmd/verifycmd_test.go b/chezmoi2/cmd/verifycmd_test.go new file mode 100644 index 000000000000..57fc17b02d1d --- /dev/null +++ b/chezmoi2/cmd/verifycmd_test.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestVerifyCmd(t *testing.T) { + for _, tc := range []struct { + name string + root interface{} + expectedErr error + }{ + { + name: "empty", + root: map[string]interface{}{ + "/home/user": &vfst.Dir{Perm: 0o700}, + }, + }, + { + name: "file", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".bashrc": "# contents of .bashrc\n", + ".local/share/chezmoi/dot_bashrc": "# contents of .bashrc\n", + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fs vfs.FS) { + assert.Equal(t, tc.expectedErr, newTestConfig(t, fs).execute([]string{"verify"})) + }) + }) + } +} diff --git a/chezmoi2/completions/chezmoi-completion.bash b/chezmoi2/completions/chezmoi-completion.bash new file mode 100644 index 000000000000..d3fd9322e6a9 --- /dev/null +++ b/chezmoi2/completions/chezmoi-completion.bash @@ -0,0 +1,2672 @@ +# bash completion for chezmoi -*- shell-script -*- + +__chezmoi_debug() +{ + if [[ -n ${BASH_COMP_DEBUG_FILE} ]]; then + echo "$*" >> "${BASH_COMP_DEBUG_FILE}" + fi +} + +# Homebrew on Macs have version 1.3 of bash-completion which doesn't include +# _init_completion. This is a very minimal version of that function. +__chezmoi_init_completion() +{ + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +__chezmoi_index_of_word() +{ + local w word=$1 + shift + index=0 + for w in "$@"; do + [[ $w = "$word" ]] && return + index=$((index+1)) + done + index=-1 +} + +__chezmoi_contains_word() +{ + local w word=$1; shift + for w in "$@"; do + [[ $w = "$word" ]] && return + done + return 1 +} + +__chezmoi_handle_go_custom_completion() +{ + __chezmoi_debug "${FUNCNAME[0]}: cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}" + + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + + local out requestComp lastParam lastChar comp directive args + + # Prepare the command to request completions for the program. + # Calling ${words[0]} instead of directly chezmoi allows to handle aliases + args=("${words[@]:1}") + requestComp="${words[0]} __completeNoDesc ${args[*]}" + + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + __chezmoi_debug "${FUNCNAME[0]}: lastParam ${lastParam}, lastChar ${lastChar}" + + if [ -z "${cur}" ] && [ "${lastChar}" != "=" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __chezmoi_debug "${FUNCNAME[0]}: Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __chezmoi_debug "${FUNCNAME[0]}: calling ${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval "${requestComp}" 2>/dev/null) + + # Extract the directive integer at the very end of the output following a colon (:) + directive=${out##*:} + # Remove the directive + out=${out%:*} + if [ "${directive}" = "${out}" ]; then + # There is not directive specified + directive=0 + fi + __chezmoi_debug "${FUNCNAME[0]}: the completion directive is: ${directive}" + __chezmoi_debug "${FUNCNAME[0]}: the completions are: ${out[*]}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + # Error code. No completion. + __chezmoi_debug "${FUNCNAME[0]}: received error from custom completion go code" + return + else + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + __chezmoi_debug "${FUNCNAME[0]}: activating no space" + compopt -o nospace + fi + fi + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + __chezmoi_debug "${FUNCNAME[0]}: activating no file completion" + compopt +o default + fi + fi + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local fullFilter filter filteringCmd + # Do not use quotes around the $out variable or else newline + # characters will be kept. + for filter in ${out[*]}; do + fullFilter+="$filter|" + done + + filteringCmd="_filedir $fullFilter" + __chezmoi_debug "File filtering command: $filteringCmd" + $filteringCmd + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subDir + # Use printf to strip any trailing newline + subdir=$(printf "%s" "${out[0]}") + if [ -n "$subdir" ]; then + __chezmoi_debug "Listing directories in $subdir" + __chezmoi_handle_subdirs_in_dir_flag "$subdir" + else + __chezmoi_debug "Listing directories in ." + _filedir -d + fi + else + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${out[*]}" -- "$cur") + fi +} + +__chezmoi_handle_reply() +{ + __chezmoi_debug "${FUNCNAME[0]}" + local comp + case $cur in + -*) + if [[ $(type -t compopt) = "builtin" ]]; then + compopt -o nospace + fi + local allflags + if [ ${#must_have_one_flag[@]} -ne 0 ]; then + allflags=("${must_have_one_flag[@]}") + else + allflags=("${flags[*]} ${two_word_flags[*]}") + fi + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${allflags[*]}" -- "$cur") + if [[ $(type -t compopt) = "builtin" ]]; then + [[ "${COMPREPLY[0]}" == *= ]] || compopt +o nospace + fi + + # complete after --flag=abc + if [[ $cur == *=* ]]; then + if [[ $(type -t compopt) = "builtin" ]]; then + compopt +o nospace + fi + + local index flag + flag="${cur%=*}" + __chezmoi_index_of_word "${flag}" "${flags_with_completion[@]}" + COMPREPLY=() + if [[ ${index} -ge 0 ]]; then + PREFIX="" + cur="${cur#*=}" + ${flags_completion[${index}]} + if [ -n "${ZSH_VERSION}" ]; then + # zsh completion needs --flag= prefix + eval "COMPREPLY=( \"\${COMPREPLY[@]/#/${flag}=}\" )" + fi + fi + fi + return 0; + ;; + esac + + # check if we are handling a flag with special work handling + local index + __chezmoi_index_of_word "${prev}" "${flags_with_completion[@]}" + if [[ ${index} -ge 0 ]]; then + ${flags_completion[${index}]} + return + fi + + # we are parsing a flag and don't have a special handler, no completion + if [[ ${cur} != "${words[cword]}" ]]; then + return + fi + + local completions + completions=("${commands[@]}") + if [[ ${#must_have_one_noun[@]} -ne 0 ]]; then + completions+=("${must_have_one_noun[@]}") + elif [[ -n "${has_completion_function}" ]]; then + # if a go completion function is provided, defer to that function + __chezmoi_handle_go_custom_completion + fi + if [[ ${#must_have_one_flag[@]} -ne 0 ]]; then + completions+=("${must_have_one_flag[@]}") + fi + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${completions[*]}" -- "$cur") + + if [[ ${#COMPREPLY[@]} -eq 0 && ${#noun_aliases[@]} -gt 0 && ${#must_have_one_noun[@]} -ne 0 ]]; then + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${noun_aliases[*]}" -- "$cur") + fi + + if [[ ${#COMPREPLY[@]} -eq 0 ]]; then + if declare -F __chezmoi_custom_func >/dev/null; then + # try command name qualified custom func + __chezmoi_custom_func + else + # otherwise fall back to unqualified for compatibility + declare -F __custom_func >/dev/null && __custom_func + fi + fi + + # available in bash-completion >= 2, not always present on macOS + if declare -F __ltrim_colon_completions >/dev/null; then + __ltrim_colon_completions "$cur" + fi + + # If there is only 1 completion and it is a flag with an = it will be completed + # but we don't want a space after the = + if [[ "${#COMPREPLY[@]}" -eq "1" ]] && [[ $(type -t compopt) = "builtin" ]] && [[ "${COMPREPLY[0]}" == --*= ]]; then + compopt -o nospace + fi +} + +# The arguments should be in the form "ext1|ext2|extn" +__chezmoi_handle_filename_extension_flag() +{ + local ext="$1" + _filedir "@(${ext})" +} + +__chezmoi_handle_subdirs_in_dir_flag() +{ + local dir="$1" + pushd "${dir}" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return +} + +__chezmoi_handle_flag() +{ + __chezmoi_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + + # if a command required a flag, and we found it, unset must_have_one_flag() + local flagname=${words[c]} + local flagvalue + # if the word contained an = + if [[ ${words[c]} == *"="* ]]; then + flagvalue=${flagname#*=} # take in as flagvalue after the = + flagname=${flagname%=*} # strip everything after the = + flagname="${flagname}=" # but put the = back + fi + __chezmoi_debug "${FUNCNAME[0]}: looking for ${flagname}" + if __chezmoi_contains_word "${flagname}" "${must_have_one_flag[@]}"; then + must_have_one_flag=() + fi + + # if you set a flag which only applies to this command, don't show subcommands + if __chezmoi_contains_word "${flagname}" "${local_nonpersistent_flags[@]}"; then + commands=() + fi + + # keep flag value with flagname as flaghash + # flaghash variable is an associative array which is only supported in bash > 3. + if [[ -z "${BASH_VERSION}" || "${BASH_VERSINFO[0]}" -gt 3 ]]; then + if [ -n "${flagvalue}" ] ; then + flaghash[${flagname}]=${flagvalue} + elif [ -n "${words[ $((c+1)) ]}" ] ; then + flaghash[${flagname}]=${words[ $((c+1)) ]} + else + flaghash[${flagname}]="true" # pad "true" for bool flag + fi + fi + + # skip the argument to a two word flag + if [[ ${words[c]} != *"="* ]] && __chezmoi_contains_word "${words[c]}" "${two_word_flags[@]}"; then + __chezmoi_debug "${FUNCNAME[0]}: found a flag ${words[c]}, skip the next argument" + c=$((c+1)) + # if we are looking for a flags value, don't show commands + if [[ $c -eq $cword ]]; then + commands=() + fi + fi + + c=$((c+1)) + +} + +__chezmoi_handle_noun() +{ + __chezmoi_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + + if __chezmoi_contains_word "${words[c]}" "${must_have_one_noun[@]}"; then + must_have_one_noun=() + elif __chezmoi_contains_word "${words[c]}" "${noun_aliases[@]}"; then + must_have_one_noun=() + fi + + nouns+=("${words[c]}") + c=$((c+1)) +} + +__chezmoi_handle_command() +{ + __chezmoi_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + + local next_command + if [[ -n ${last_command} ]]; then + next_command="_${last_command}_${words[c]//:/__}" + else + if [[ $c -eq 0 ]]; then + next_command="_chezmoi_root_command" + else + next_command="_${words[c]//:/__}" + fi + fi + c=$((c+1)) + __chezmoi_debug "${FUNCNAME[0]}: looking for ${next_command}" + declare -F "$next_command" >/dev/null && $next_command +} + +__chezmoi_handle_word() +{ + if [[ $c -ge $cword ]]; then + __chezmoi_handle_reply + return + fi + __chezmoi_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + if [[ "${words[c]}" == -* ]]; then + __chezmoi_handle_flag + elif __chezmoi_contains_word "${words[c]}" "${commands[@]}"; then + __chezmoi_handle_command + elif [[ $c -eq 0 ]]; then + __chezmoi_handle_command + elif __chezmoi_contains_word "${words[c]}" "${command_aliases[@]}"; then + # aliashash variable is an associative array which is only supported in bash > 3. + if [[ -z "${BASH_VERSION}" || "${BASH_VERSINFO[0]}" -gt 3 ]]; then + words[c]=${aliashash[${words[c]}]} + __chezmoi_handle_command + else + __chezmoi_handle_noun + fi + else + __chezmoi_handle_noun + fi + __chezmoi_handle_word +} + +_chezmoi_add() +{ + last_command="chezmoi_add" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--autotemplate") + flags+=("-a") + local_nonpersistent_flags+=("--autotemplate") + local_nonpersistent_flags+=("-a") + flags+=("--empty") + flags+=("-e") + local_nonpersistent_flags+=("--empty") + local_nonpersistent_flags+=("-e") + flags+=("--encrypt") + local_nonpersistent_flags+=("--encrypt") + flags+=("--exact") + flags+=("-x") + local_nonpersistent_flags+=("--exact") + local_nonpersistent_flags+=("-x") + flags+=("--recursive") + flags+=("-r") + local_nonpersistent_flags+=("--recursive") + local_nonpersistent_flags+=("-r") + flags+=("--template") + flags+=("-T") + local_nonpersistent_flags+=("--template") + local_nonpersistent_flags+=("-T") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_apply() +{ + last_command="chezmoi_apply" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--include=") + two_word_flags+=("--include") + two_word_flags+=("-i") + local_nonpersistent_flags+=("--include") + local_nonpersistent_flags+=("--include=") + local_nonpersistent_flags+=("-i") + flags+=("--recursive") + flags+=("-r") + local_nonpersistent_flags+=("--recursive") + local_nonpersistent_flags+=("-r") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_archive() +{ + last_command="chezmoi_archive" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--gzip") + flags+=("-z") + local_nonpersistent_flags+=("--gzip") + local_nonpersistent_flags+=("-z") + flags+=("--include=") + two_word_flags+=("--include") + two_word_flags+=("-i") + local_nonpersistent_flags+=("--include") + local_nonpersistent_flags+=("--include=") + local_nonpersistent_flags+=("-i") + flags+=("--recursive") + flags+=("-r") + local_nonpersistent_flags+=("--recursive") + local_nonpersistent_flags+=("-r") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_cat() +{ + last_command="chezmoi_cat" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_cd() +{ + last_command="chezmoi_cd" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_chattr() +{ + last_command="chezmoi_chattr" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + must_have_one_noun+=("+e") + must_have_one_noun+=("+empty") + must_have_one_noun+=("+encrypted") + must_have_one_noun+=("+exact") + must_have_one_noun+=("+executable") + must_have_one_noun+=("+f") + must_have_one_noun+=("+first") + must_have_one_noun+=("+l") + must_have_one_noun+=("+last") + must_have_one_noun+=("+o") + must_have_one_noun+=("+once") + must_have_one_noun+=("+p") + must_have_one_noun+=("+private") + must_have_one_noun+=("+t") + must_have_one_noun+=("+template") + must_have_one_noun+=("+x") + must_have_one_noun+=("-e") + must_have_one_noun+=("-empty") + must_have_one_noun+=("-encrypted") + must_have_one_noun+=("-exact") + must_have_one_noun+=("-executable") + must_have_one_noun+=("-f") + must_have_one_noun+=("-first") + must_have_one_noun+=("-l") + must_have_one_noun+=("-last") + must_have_one_noun+=("-o") + must_have_one_noun+=("-once") + must_have_one_noun+=("-p") + must_have_one_noun+=("-private") + must_have_one_noun+=("-t") + must_have_one_noun+=("-template") + must_have_one_noun+=("-x") + must_have_one_noun+=("e") + must_have_one_noun+=("empty") + must_have_one_noun+=("encrypted") + must_have_one_noun+=("exact") + must_have_one_noun+=("executable") + must_have_one_noun+=("f") + must_have_one_noun+=("first") + must_have_one_noun+=("l") + must_have_one_noun+=("last") + must_have_one_noun+=("noe") + must_have_one_noun+=("noempty") + must_have_one_noun+=("noencrypted") + must_have_one_noun+=("noexact") + must_have_one_noun+=("noexecutable") + must_have_one_noun+=("nof") + must_have_one_noun+=("nofirst") + must_have_one_noun+=("nol") + must_have_one_noun+=("nolast") + must_have_one_noun+=("noo") + must_have_one_noun+=("noonce") + must_have_one_noun+=("nop") + must_have_one_noun+=("noprivate") + must_have_one_noun+=("not") + must_have_one_noun+=("notemplate") + must_have_one_noun+=("nox") + must_have_one_noun+=("o") + must_have_one_noun+=("once") + must_have_one_noun+=("p") + must_have_one_noun+=("private") + must_have_one_noun+=("t") + must_have_one_noun+=("template") + must_have_one_noun+=("x") + noun_aliases=() +} + +_chezmoi_completion() +{ + last_command="chezmoi_completion" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--help") + flags+=("-h") + local_nonpersistent_flags+=("--help") + local_nonpersistent_flags+=("-h") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + must_have_one_noun+=("bash") + must_have_one_noun+=("fish") + must_have_one_noun+=("powershell") + must_have_one_noun+=("zsh") + noun_aliases=() +} + +_chezmoi_data() +{ + last_command="chezmoi_data" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_diff() +{ + last_command="chezmoi_diff" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--include=") + two_word_flags+=("--include") + two_word_flags+=("-i") + local_nonpersistent_flags+=("--include") + local_nonpersistent_flags+=("--include=") + local_nonpersistent_flags+=("-i") + flags+=("--no-pager") + local_nonpersistent_flags+=("--no-pager") + flags+=("--recursive") + flags+=("-r") + local_nonpersistent_flags+=("--recursive") + local_nonpersistent_flags+=("-r") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_docs() +{ + last_command="chezmoi_docs" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_doctor() +{ + last_command="chezmoi_doctor" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_dump() +{ + last_command="chezmoi_dump" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--include=") + two_word_flags+=("--include") + two_word_flags+=("-i") + local_nonpersistent_flags+=("--include") + local_nonpersistent_flags+=("--include=") + local_nonpersistent_flags+=("-i") + flags+=("--recursive") + flags+=("-r") + local_nonpersistent_flags+=("--recursive") + local_nonpersistent_flags+=("-r") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_edit() +{ + last_command="chezmoi_edit" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--apply") + flags+=("-a") + local_nonpersistent_flags+=("--apply") + local_nonpersistent_flags+=("-a") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_edit-config() +{ + last_command="chezmoi_edit-config" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_execute-template() +{ + last_command="chezmoi_execute-template" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--init") + flags+=("-i") + local_nonpersistent_flags+=("--init") + local_nonpersistent_flags+=("-i") + flags+=("--promptBool=") + two_word_flags+=("--promptBool") + local_nonpersistent_flags+=("--promptBool") + local_nonpersistent_flags+=("--promptBool=") + flags+=("--promptInt=") + two_word_flags+=("--promptInt") + local_nonpersistent_flags+=("--promptInt") + local_nonpersistent_flags+=("--promptInt=") + flags+=("--promptString=") + two_word_flags+=("--promptString") + two_word_flags+=("-p") + local_nonpersistent_flags+=("--promptString") + local_nonpersistent_flags+=("--promptString=") + local_nonpersistent_flags+=("-p") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_forget() +{ + last_command="chezmoi_forget" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_git() +{ + last_command="chezmoi_git" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_help() +{ + last_command="chezmoi_help" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_init() +{ + last_command="chezmoi_init" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--apply") + flags+=("-a") + local_nonpersistent_flags+=("--apply") + local_nonpersistent_flags+=("-a") + flags+=("--depth=") + two_word_flags+=("--depth") + two_word_flags+=("-d") + local_nonpersistent_flags+=("--depth") + local_nonpersistent_flags+=("--depth=") + local_nonpersistent_flags+=("-d") + flags+=("--purge") + flags+=("-p") + local_nonpersistent_flags+=("--purge") + local_nonpersistent_flags+=("-p") + flags+=("--purge-binary") + flags+=("-P") + local_nonpersistent_flags+=("--purge-binary") + local_nonpersistent_flags+=("-P") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_managed() +{ + last_command="chezmoi_managed" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--include=") + two_word_flags+=("--include") + two_word_flags+=("-i") + local_nonpersistent_flags+=("--include") + local_nonpersistent_flags+=("--include=") + local_nonpersistent_flags+=("-i") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_merge() +{ + last_command="chezmoi_merge" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_purge() +{ + last_command="chezmoi_purge" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--binary") + flags+=("-P") + local_nonpersistent_flags+=("--binary") + local_nonpersistent_flags+=("-P") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_remove() +{ + last_command="chezmoi_remove" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_source-path() +{ + last_command="chezmoi_source-path" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_state_dump() +{ + last_command="chezmoi_state_dump" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_state_reset() +{ + last_command="chezmoi_state_reset" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_state() +{ + last_command="chezmoi_state" + + command_aliases=() + + commands=() + commands+=("dump") + commands+=("reset") + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_status() +{ + last_command="chezmoi_status" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--include=") + two_word_flags+=("--include") + two_word_flags+=("-i") + local_nonpersistent_flags+=("--include") + local_nonpersistent_flags+=("--include=") + local_nonpersistent_flags+=("-i") + flags+=("--recursive") + flags+=("-r") + local_nonpersistent_flags+=("--recursive") + local_nonpersistent_flags+=("-r") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_unmanaged() +{ + last_command="chezmoi_unmanaged" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_update() +{ + last_command="chezmoi_update" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--apply") + flags+=("-a") + local_nonpersistent_flags+=("--apply") + local_nonpersistent_flags+=("-a") + flags+=("--include=") + two_word_flags+=("--include") + two_word_flags+=("-i") + local_nonpersistent_flags+=("--include") + local_nonpersistent_flags+=("--include=") + local_nonpersistent_flags+=("-i") + flags+=("--recursive") + flags+=("-r") + local_nonpersistent_flags+=("--recursive") + local_nonpersistent_flags+=("-r") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_verify() +{ + last_command="chezmoi_verify" + + command_aliases=() + + commands=() + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--include=") + two_word_flags+=("--include") + two_word_flags+=("-i") + local_nonpersistent_flags+=("--include") + local_nonpersistent_flags+=("--include=") + local_nonpersistent_flags+=("-i") + flags+=("--recursive") + flags+=("-r") + local_nonpersistent_flags+=("--recursive") + local_nonpersistent_flags+=("-r") + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +_chezmoi_root_command() +{ + last_command="chezmoi" + + command_aliases=() + + commands=() + commands+=("add") + if [[ -z "${BASH_VERSION}" || "${BASH_VERSINFO[0]}" -gt 3 ]]; then + command_aliases+=("manage") + aliashash["manage"]="add" + fi + commands+=("apply") + commands+=("archive") + commands+=("cat") + commands+=("cd") + commands+=("chattr") + commands+=("completion") + commands+=("data") + commands+=("diff") + commands+=("docs") + commands+=("doctor") + commands+=("dump") + commands+=("edit") + commands+=("edit-config") + commands+=("execute-template") + commands+=("forget") + if [[ -z "${BASH_VERSION}" || "${BASH_VERSINFO[0]}" -gt 3 ]]; then + command_aliases+=("unmanage") + aliashash["unmanage"]="forget" + fi + commands+=("git") + commands+=("help") + commands+=("init") + commands+=("managed") + commands+=("merge") + commands+=("purge") + commands+=("remove") + if [[ -z "${BASH_VERSION}" || "${BASH_VERSINFO[0]}" -gt 3 ]]; then + command_aliases+=("rm") + aliashash["rm"]="remove" + fi + commands+=("source-path") + commands+=("state") + commands+=("status") + commands+=("unmanaged") + commands+=("update") + commands+=("verify") + + flags=() + two_word_flags=() + local_nonpersistent_flags=() + flags_with_completion=() + flags_completion=() + + flags+=("--color=") + two_word_flags+=("--color") + flags+=("--config=") + two_word_flags+=("--config") + flags_with_completion+=("--config") + flags_completion+=("_filedir") + two_word_flags+=("-c") + flags_with_completion+=("-c") + flags_completion+=("_filedir") + flags+=("--debug") + flags+=("--destination=") + two_word_flags+=("--destination") + flags_with_completion+=("--destination") + flags_completion+=("_filedir -d") + two_word_flags+=("-D") + flags_with_completion+=("-D") + flags_completion+=("_filedir -d") + flags+=("--dry-run") + flags+=("-n") + flags+=("--force") + flags+=("--format=") + two_word_flags+=("--format") + flags+=("--keep-going") + flags+=("-k") + flags+=("--output=") + two_word_flags+=("--output") + flags_with_completion+=("--output") + flags_completion+=("_filedir") + two_word_flags+=("-o") + flags_with_completion+=("-o") + flags_completion+=("_filedir") + flags+=("--remove") + flags+=("--source=") + two_word_flags+=("--source") + flags_with_completion+=("--source") + flags_completion+=("_filedir -d") + two_word_flags+=("-S") + flags_with_completion+=("-S") + flags_completion+=("_filedir -d") + flags+=("--use-builtin-git=") + two_word_flags+=("--use-builtin-git") + flags+=("--verbose") + flags+=("-v") + + must_have_one_flag=() + must_have_one_noun=() + noun_aliases=() +} + +__start_chezmoi() +{ + local cur prev words cword + declare -A flaghash 2>/dev/null || : + declare -A aliashash 2>/dev/null || : + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -s || return + else + __chezmoi_init_completion -n "=" || return + fi + + local c=0 + local flags=() + local two_word_flags=() + local local_nonpersistent_flags=() + local flags_with_completion=() + local flags_completion=() + local commands=("chezmoi") + local must_have_one_flag=() + local must_have_one_noun=() + local has_completion_function + local last_command + local nouns=() + + __chezmoi_handle_word +} + +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_chezmoi chezmoi +else + complete -o default -o nospace -F __start_chezmoi chezmoi +fi + +# ex: ts=4 sw=4 et filetype=sh diff --git a/chezmoi2/completions/chezmoi.fish b/chezmoi2/completions/chezmoi.fish new file mode 100644 index 000000000000..39bfb29ba909 --- /dev/null +++ b/chezmoi2/completions/chezmoi.fish @@ -0,0 +1,164 @@ +# fish completion for chezmoi -*- shell-script -*- + +function __chezmoi_debug + set file "$BASH_COMP_DEBUG_FILE" + if test -n "$file" + echo "$argv" >> $file + end +end + +function __chezmoi_perform_completion + __chezmoi_debug "Starting __chezmoi_perform_completion with: $argv" + + set args (string split -- " " "$argv") + set lastArg "$args[-1]" + + __chezmoi_debug "args: $args" + __chezmoi_debug "last arg: $lastArg" + + set emptyArg "" + if test -z "$lastArg" + __chezmoi_debug "Setting emptyArg" + set emptyArg \"\" + end + __chezmoi_debug "emptyArg: $emptyArg" + + if not type -q "$args[1]" + # This can happen when "complete --do-complete chezmoi" is called when running this script. + __chezmoi_debug "Cannot find $args[1]. No completions." + return + end + + set requestComp "$args[1] __complete $args[2..-1] $emptyArg" + __chezmoi_debug "Calling $requestComp" + + set results (eval $requestComp 2> /dev/null) + set comps $results[1..-2] + set directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set flagPrefix (string match -r -- '-.*=' "$lastArg") + + __chezmoi_debug "Comps: $comps" + __chezmoi_debug "DirectiveLine: $directiveLine" + __chezmoi_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\n" "$flagPrefix" "$comp" + end + + printf "%s\n" "$directiveLine" +end + +# This function does three things: +# 1- Obtain the completions and store them in the global __chezmoi_comp_results +# 2- Set the __chezmoi_comp_do_file_comp flag if file completion should be performed +# and unset it otherwise +# 3- Return true if the completion results are not empty +function __chezmoi_prepare_completions + # Start fresh + set --erase __chezmoi_comp_do_file_comp + set --erase __chezmoi_comp_results + + # Check if the command-line is already provided. This is useful for testing. + if not set --query __chezmoi_comp_commandLine + # Use the -c flag to allow for completion in the middle of the line + set __chezmoi_comp_commandLine (commandline -c) + end + __chezmoi_debug "commandLine is: $__chezmoi_comp_commandLine" + + set results (__chezmoi_perform_completion "$__chezmoi_comp_commandLine") + set --erase __chezmoi_comp_commandLine + __chezmoi_debug "Completion results: $results" + + if test -z "$results" + __chezmoi_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + set --global __chezmoi_comp_do_file_comp 1 + return 1 + end + + set directive (string sub --start 2 $results[-1]) + set --global __chezmoi_comp_results $results[1..-2] + + __chezmoi_debug "Completions are: $__chezmoi_comp_results" + __chezmoi_debug "Directive is: $directive" + + set shellCompDirectiveError 1 + set shellCompDirectiveNoSpace 2 + set shellCompDirectiveNoFileComp 4 + set shellCompDirectiveFilterFileExt 8 + set shellCompDirectiveFilterDirs 16 + + if test -z "$directive" + set directive 0 + end + + set compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __chezmoi_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + set --global __chezmoi_comp_do_file_comp 1 + return 1 + end + + set filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __chezmoi_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + set --global __chezmoi_comp_do_file_comp 1 + return 1 + end + + set nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __chezmoi_debug "nospace: $nospace, nofiles: $nofiles" + + # Important not to quote the variable for count to work + set numComps (count $__chezmoi_comp_results) + __chezmoi_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # To support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __chezmoi_debug "Adding second completion to perform nospace directive" + set --append __chezmoi_comp_results $__chezmoi_comp_results[1]. + end + + if test $numComps -eq 0; and test $nofiles -eq 0 + __chezmoi_debug "Requesting file completion" + set --global __chezmoi_comp_do_file_comp 1 + end + + # If we don't want file completion, we must return true even if there + # are no completions found. This is because fish will perform the last + # completion command, even if its condition is false, if no other + # completion command was triggered + return (not set --query __chezmoi_comp_do_file_comp) +end + +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# The space after the the program name is essential to trigger completion for the program +# and not completion of the program name itself. +complete --do-complete "chezmoi " > /dev/null 2>&1 +# Using '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c chezmoi -e + +# The order in which the below two lines are defined is very important so that __chezmoi_prepare_completions +# is called first. It is __chezmoi_prepare_completions that sets up the __chezmoi_comp_do_file_comp variable. +# +# This completion will be run second as complete commands are added FILO. +# It triggers file completion choices when __chezmoi_comp_do_file_comp is set. +complete -c chezmoi -n 'set --query __chezmoi_comp_do_file_comp' + +# This completion will be run first as complete commands are added FILO. +# The call to __chezmoi_prepare_completions will setup both __chezmoi_comp_results and __chezmoi_comp_do_file_comp. +# It provides the program's completion choices. +complete -c chezmoi -n '__chezmoi_prepare_completions' -f -a '$__chezmoi_comp_results' + diff --git a/chezmoi2/completions/chezmoi.ps1 b/chezmoi2/completions/chezmoi.ps1 new file mode 100644 index 000000000000..d342320895b2 --- /dev/null +++ b/chezmoi2/completions/chezmoi.ps1 @@ -0,0 +1,255 @@ +using namespace System.Management.Automation +using namespace System.Management.Automation.Language +Register-ArgumentCompleter -Native -CommandName 'chezmoi' -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $commandElements = $commandAst.CommandElements + $command = @( + 'chezmoi' + for ($i = 1; $i -lt $commandElements.Count; $i++) { + $element = $commandElements[$i] + if ($element -isnot [StringConstantExpressionAst] -or + $element.StringConstantType -ne [StringConstantType]::BareWord -or + $element.Value.StartsWith('-')) { + break + } + $element.Value + } + ) -join ';' + $completions = @(switch ($command) { + 'chezmoi' { + [CompletionResult]::new('--color', 'color', [CompletionResultType]::ParameterName, 'colorize diffs') + [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'config file') + [CompletionResult]::new('--config', 'config', [CompletionResultType]::ParameterName, 'config file') + [CompletionResult]::new('--debug', 'debug', [CompletionResultType]::ParameterName, 'write debug logs') + [CompletionResult]::new('-D', 'D', [CompletionResultType]::ParameterName, 'destination directory') + [CompletionResult]::new('--destination', 'destination', [CompletionResultType]::ParameterName, 'destination directory') + [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'dry run') + [CompletionResult]::new('--dry-run', 'dry-run', [CompletionResultType]::ParameterName, 'dry run') + [CompletionResult]::new('--force', 'force', [CompletionResultType]::ParameterName, 'force') + [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'format (json, toml, or yaml)') + [CompletionResult]::new('-k', 'k', [CompletionResultType]::ParameterName, 'keep going as far as possible after an error') + [CompletionResult]::new('--keep-going', 'keep-going', [CompletionResultType]::ParameterName, 'keep going as far as possible after an error') + [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'output file') + [CompletionResult]::new('--output', 'output', [CompletionResultType]::ParameterName, 'output file') + [CompletionResult]::new('--remove', 'remove', [CompletionResultType]::ParameterName, 'remove targets') + [CompletionResult]::new('-S', 'S', [CompletionResultType]::ParameterName, 'source directory') + [CompletionResult]::new('--source', 'source', [CompletionResultType]::ParameterName, 'source directory') + [CompletionResult]::new('--use-builtin-git', 'use-builtin-git', [CompletionResultType]::ParameterName, 'use builtin git') + [CompletionResult]::new('-v', 'v', [CompletionResultType]::ParameterName, 'verbose') + [CompletionResult]::new('--verbose', 'verbose', [CompletionResultType]::ParameterName, 'verbose') + [CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add an existing file, directory, or symlink to the source state') + [CompletionResult]::new('apply', 'apply', [CompletionResultType]::ParameterValue, 'Update the destination directory to match the target state') + [CompletionResult]::new('archive', 'archive', [CompletionResultType]::ParameterValue, 'Generate a tar archive of the target state') + [CompletionResult]::new('cat', 'cat', [CompletionResultType]::ParameterValue, 'Print the target contents of a file or symlink') + [CompletionResult]::new('cd', 'cd', [CompletionResultType]::ParameterValue, 'Launch a shell in the source directory') + [CompletionResult]::new('chattr', 'chattr', [CompletionResultType]::ParameterValue, 'Change the attributes of a target in the source state') + [CompletionResult]::new('completion', 'completion', [CompletionResultType]::ParameterValue, 'Generate shell completion code') + [CompletionResult]::new('data', 'data', [CompletionResultType]::ParameterValue, 'Print the template data') + [CompletionResult]::new('diff', 'diff', [CompletionResultType]::ParameterValue, 'Print the diff between the target state and the destination state') + [CompletionResult]::new('docs', 'docs', [CompletionResultType]::ParameterValue, 'Print documentation') + [CompletionResult]::new('doctor', 'doctor', [CompletionResultType]::ParameterValue, 'Check your system for potential problems') + [CompletionResult]::new('dump', 'dump', [CompletionResultType]::ParameterValue, 'Generate a dump of the target state') + [CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Edit the source state of a target') + [CompletionResult]::new('edit-config', 'edit-config', [CompletionResultType]::ParameterValue, 'Edit the configuration file') + [CompletionResult]::new('execute-template', 'execute-template', [CompletionResultType]::ParameterValue, 'Execute the given template(s)') + [CompletionResult]::new('forget', 'forget', [CompletionResultType]::ParameterValue, 'Remove a target from the source state') + [CompletionResult]::new('git', 'git', [CompletionResultType]::ParameterValue, 'Run git in the source directory') + [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print help about a command') + [CompletionResult]::new('init', 'init', [CompletionResultType]::ParameterValue, 'Setup the source directory and update the destination directory to match the target state') + [CompletionResult]::new('managed', 'managed', [CompletionResultType]::ParameterValue, 'List the managed entries in the destination directory') + [CompletionResult]::new('merge', 'merge', [CompletionResultType]::ParameterValue, 'Perform a three-way merge between the destination state, the source state, and the target state') + [CompletionResult]::new('purge', 'purge', [CompletionResultType]::ParameterValue, 'Purge chezmoi''s configuration and data') + [CompletionResult]::new('remove', 'remove', [CompletionResultType]::ParameterValue, 'Remove a target from the source state and the destination directory') + [CompletionResult]::new('source-path', 'source-path', [CompletionResultType]::ParameterValue, 'Print the path of a target in the source state') + [CompletionResult]::new('state', 'state', [CompletionResultType]::ParameterValue, 'Manipulate the persistent state') + [CompletionResult]::new('status', 'status', [CompletionResultType]::ParameterValue, 'Show the status of targets') + [CompletionResult]::new('unmanaged', 'unmanaged', [CompletionResultType]::ParameterValue, 'List the unmanaged files in the destination directory') + [CompletionResult]::new('update', 'update', [CompletionResultType]::ParameterValue, 'Pull and apply any changes') + [CompletionResult]::new('verify', 'verify', [CompletionResultType]::ParameterValue, 'Exit with success if the destination state matches the target state, fail otherwise') + break + } + 'chezmoi;add' { + [CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'auto generate the template when adding files as templates') + [CompletionResult]::new('--autotemplate', 'autotemplate', [CompletionResultType]::ParameterName, 'auto generate the template when adding files as templates') + [CompletionResult]::new('-e', 'e', [CompletionResultType]::ParameterName, 'add empty files') + [CompletionResult]::new('--empty', 'empty', [CompletionResultType]::ParameterName, 'add empty files') + [CompletionResult]::new('--encrypt', 'encrypt', [CompletionResultType]::ParameterName, 'encrypt files') + [CompletionResult]::new('-x', 'x', [CompletionResultType]::ParameterName, 'add directories exactly') + [CompletionResult]::new('--exact', 'exact', [CompletionResultType]::ParameterName, 'add directories exactly') + [CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'recursive') + [CompletionResult]::new('--recursive', 'recursive', [CompletionResultType]::ParameterName, 'recursive') + [CompletionResult]::new('-T', 'T', [CompletionResultType]::ParameterName, 'add files as templates') + [CompletionResult]::new('--template', 'template', [CompletionResultType]::ParameterName, 'add files as templates') + break + } + 'chezmoi;apply' { + [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('--include', 'include', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'recursive') + [CompletionResult]::new('--recursive', 'recursive', [CompletionResultType]::ParameterName, 'recursive') + break + } + 'chezmoi;archive' { + [CompletionResult]::new('-z', 'z', [CompletionResultType]::ParameterName, 'compress the output with gzip') + [CompletionResult]::new('--gzip', 'gzip', [CompletionResultType]::ParameterName, 'compress the output with gzip') + [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('--include', 'include', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'recursive') + [CompletionResult]::new('--recursive', 'recursive', [CompletionResultType]::ParameterName, 'recursive') + break + } + 'chezmoi;cat' { + break + } + 'chezmoi;cd' { + break + } + 'chezmoi;chattr' { + break + } + 'chezmoi;completion' { + [CompletionResult]::new('--color', 'color', [CompletionResultType]::ParameterName, 'colorize diffs') + [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'config file') + [CompletionResult]::new('--config', 'config', [CompletionResultType]::ParameterName, 'config file') + [CompletionResult]::new('--debug', 'debug', [CompletionResultType]::ParameterName, 'write debug logs') + [CompletionResult]::new('-D', 'D', [CompletionResultType]::ParameterName, 'destination directory') + [CompletionResult]::new('--destination', 'destination', [CompletionResultType]::ParameterName, 'destination directory') + [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'dry run') + [CompletionResult]::new('--dry-run', 'dry-run', [CompletionResultType]::ParameterName, 'dry run') + [CompletionResult]::new('--force', 'force', [CompletionResultType]::ParameterName, 'force') + [CompletionResult]::new('--format', 'format', [CompletionResultType]::ParameterName, 'format (json, toml, or yaml)') + [CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'help for completion') + [CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'help for completion') + [CompletionResult]::new('-k', 'k', [CompletionResultType]::ParameterName, 'keep going as far as possible after an error') + [CompletionResult]::new('--keep-going', 'keep-going', [CompletionResultType]::ParameterName, 'keep going as far as possible after an error') + [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'output file') + [CompletionResult]::new('--output', 'output', [CompletionResultType]::ParameterName, 'output file') + [CompletionResult]::new('--remove', 'remove', [CompletionResultType]::ParameterName, 'remove targets') + [CompletionResult]::new('-S', 'S', [CompletionResultType]::ParameterName, 'source directory') + [CompletionResult]::new('--source', 'source', [CompletionResultType]::ParameterName, 'source directory') + [CompletionResult]::new('--use-builtin-git', 'use-builtin-git', [CompletionResultType]::ParameterName, 'use builtin git') + [CompletionResult]::new('-v', 'v', [CompletionResultType]::ParameterName, 'verbose') + [CompletionResult]::new('--verbose', 'verbose', [CompletionResultType]::ParameterName, 'verbose') + break + } + 'chezmoi;data' { + break + } + 'chezmoi;diff' { + [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('--include', 'include', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('--no-pager', 'no-pager', [CompletionResultType]::ParameterName, 'disable pager') + [CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'recursive') + [CompletionResult]::new('--recursive', 'recursive', [CompletionResultType]::ParameterName, 'recursive') + break + } + 'chezmoi;docs' { + break + } + 'chezmoi;doctor' { + break + } + 'chezmoi;dump' { + [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('--include', 'include', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'recursive') + [CompletionResult]::new('--recursive', 'recursive', [CompletionResultType]::ParameterName, 'recursive') + break + } + 'chezmoi;edit' { + [CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'apply edit after editing') + [CompletionResult]::new('--apply', 'apply', [CompletionResultType]::ParameterName, 'apply edit after editing') + break + } + 'chezmoi;edit-config' { + break + } + 'chezmoi;execute-template' { + [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'simulate chezmoi init') + [CompletionResult]::new('--init', 'init', [CompletionResultType]::ParameterName, 'simulate chezmoi init') + [CompletionResult]::new('--promptBool', 'promptBool', [CompletionResultType]::ParameterName, 'simulate promptBool') + [CompletionResult]::new('--promptInt', 'promptInt', [CompletionResultType]::ParameterName, 'simulate promptInt') + [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'simulate promptString') + [CompletionResult]::new('--promptString', 'promptString', [CompletionResultType]::ParameterName, 'simulate promptString') + break + } + 'chezmoi;forget' { + break + } + 'chezmoi;git' { + break + } + 'chezmoi;help' { + break + } + 'chezmoi;init' { + [CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'update destination directory') + [CompletionResult]::new('--apply', 'apply', [CompletionResultType]::ParameterName, 'update destination directory') + [CompletionResult]::new('-d', 'd', [CompletionResultType]::ParameterName, 'create a shallow clone') + [CompletionResult]::new('--depth', 'depth', [CompletionResultType]::ParameterName, 'create a shallow clone') + [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'purge config and source directories') + [CompletionResult]::new('--purge', 'purge', [CompletionResultType]::ParameterName, 'purge config and source directories') + [CompletionResult]::new('-P', 'P', [CompletionResultType]::ParameterName, 'purge chezmoi binary') + [CompletionResult]::new('--purge-binary', 'purge-binary', [CompletionResultType]::ParameterName, 'purge chezmoi binary') + break + } + 'chezmoi;managed' { + [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('--include', 'include', [CompletionResultType]::ParameterName, 'include entry types') + break + } + 'chezmoi;merge' { + break + } + 'chezmoi;purge' { + [CompletionResult]::new('-P', 'P', [CompletionResultType]::ParameterName, 'purge chezmoi executable') + [CompletionResult]::new('--binary', 'binary', [CompletionResultType]::ParameterName, 'purge chezmoi executable') + break + } + 'chezmoi;remove' { + break + } + 'chezmoi;source-path' { + break + } + 'chezmoi;state' { + [CompletionResult]::new('dump', 'dump', [CompletionResultType]::ParameterValue, 'Generate a dump of the persistent state') + [CompletionResult]::new('reset', 'reset', [CompletionResultType]::ParameterValue, 'Reset the persistent state') + break + } + 'chezmoi;state;dump' { + break + } + 'chezmoi;state;reset' { + break + } + 'chezmoi;status' { + [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('--include', 'include', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'recursive') + [CompletionResult]::new('--recursive', 'recursive', [CompletionResultType]::ParameterName, 'recursive') + break + } + 'chezmoi;unmanaged' { + break + } + 'chezmoi;update' { + [CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'apply after pulling') + [CompletionResult]::new('--apply', 'apply', [CompletionResultType]::ParameterName, 'apply after pulling') + [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('--include', 'include', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'recursive') + [CompletionResult]::new('--recursive', 'recursive', [CompletionResultType]::ParameterName, 'recursive') + break + } + 'chezmoi;verify' { + [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('--include', 'include', [CompletionResultType]::ParameterName, 'include entry types') + [CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'recursive') + [CompletionResult]::new('--recursive', 'recursive', [CompletionResultType]::ParameterName, 'recursive') + break + } + }) + $completions.Where{ $_.CompletionText -like "$wordToComplete*" } | + Sort-Object -Property ListItemText +} \ No newline at end of file diff --git a/chezmoi2/completions/chezmoi.zsh b/chezmoi2/completions/chezmoi.zsh new file mode 100644 index 000000000000..d92d02f9443e --- /dev/null +++ b/chezmoi2/completions/chezmoi.zsh @@ -0,0 +1,159 @@ +#compdef _chezmoi chezmoi + +# zsh completion for chezmoi -*- shell-script -*- + +__chezmoi_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +_chezmoi() +{ + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + + local lastParam lastChar flagPrefix requestComp out directive compCount comp lastComp + local -a completions + + __chezmoi_debug "\n========= starting completion logic ==========" + __chezmoi_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") + __chezmoi_debug "Truncated words[*]: ${words[*]}," + + lastParam=${words[-1]} + lastChar=${lastParam[-1]} + __chezmoi_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" + + # For zsh, when completing a flag with an = (e.g., chezmoi -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[1]} __complete ${words[2,-1]}" + if [ "${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __chezmoi_debug "Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __chezmoi_debug "About to call: eval ${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + __chezmoi_debug "completion output: ${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=${line} + done < <(printf "%s\n" "${out[@]}") + __chezmoi_debug "last line: ${lastLine}" + + if [ "${lastLine[1]}" = : ]; then + directive=${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=${#lastLine}+2)) + out=${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __chezmoi_debug "No directive found. Setting do default" + directive=0 + fi + + __chezmoi_debug "directive: ${directive}" + __chezmoi_debug "completions: ${out}" + __chezmoi_debug "flagPrefix: ${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __chezmoi_debug "Completion received error. Ignoring completions." + return + fi + + compCount=0 + while IFS='\n' read -r comp; do + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + + local tab=$(printf '\t') + comp=${comp//$tab/:} + + ((compCount++)) + __chezmoi_debug "Adding completion: ${comp}" + completions+=${comp} + lastComp=$comp + fi + done < <(printf "%s\n" "${out[@]}") + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in ${completions[@]}; do + if [ ${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\*.$filter" + fi + filteringCmd+=" -g $filter" + done + filteringCmd+=" ${flagPrefix}" + + __chezmoi_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subDir + subdir="${completions[1]}" + if [ -n "$subdir" ]; then + __chezmoi_debug "Listing directories in $subdir" + pushd "${subdir}" >/dev/null 2>&1 + else + __chezmoi_debug "Listing directories in ." + fi + + _arguments '*:dirname:_files -/'" ${flagPrefix}" + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + elif [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ] && [ ${compCount} -eq 1 ]; then + __chezmoi_debug "Activating nospace." + # We can use compadd here as there is no description when + # there is only one completion. + compadd -S '' "${lastComp}" + elif [ ${compCount} -eq 0 ]; then + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __chezmoi_debug "deactivating file completion" + else + # Perform file completion + __chezmoi_debug "activating file completion" + _arguments '*:filename:_files'" ${flagPrefix}" + fi + else + _describe "completions" completions $(echo $flagPrefix) + fi +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_chezmoi" ]; then + _chezmoi +fi diff --git a/chezmoi2/docs/CHANGES.md b/chezmoi2/docs/CHANGES.md new file mode 100644 index 000000000000..5b4a2febb0ec --- /dev/null +++ b/chezmoi2/docs/CHANGES.md @@ -0,0 +1,48 @@ +## Changes in v2, already done + +General: +- `--recursive` is default for some commands, notably `chezmoi add` +- only diff format is git +- remove hg support +- remove source command (use git instead) +- `--include` option to many commands +- errors output to stderr, not stdout +- `--force` now global +- `--output` now global +- diff includes scripts +- archive includes scripts +- `encrypt` -> `encrypted` in chattr +- `--format` now global, don't use toml for dump +- `y`, `yes`, `on`, `n`, `no`, `off` recognized as bools +- order for `merge` is now dest, target, source +- No more `--prompt` to `chezmoi edit` +- `--keep-going` global +- `chezmoi init` guesses your repo URL if you use github.com and dotfiles +- `edit.command` and `edit.args` settable in config file, overrides `$EDITOR` / `$VISUAL` +- state data has changed, `run_once_` scripts will be run again +- `init` gets `--depth` and `--purge` +- `run_once_` scripts with same content but different names will only be run once +- global `--use-builtin-git` +- added `.chezmoi.version` template var +- added `gitHubKeys` template func uses `CHEZMOI_GITHUB_ACCESS_TOKEN`, `GITHUB_ACCESS_TOKEN`, and `GITHUB_TOKEN` first non-empty +- template data on a best-effort basis, errors ignored +- `chezmoi status` +- `chezmoi apply` no longer overwrites by default +- `chezmoi init --one-shot` +- new type `--exists` +- `chezmoi archive --format=zip` +- `first_` and `last_` script attributes change script order, scripts now run during +- new `fqdnHostname` template var (UNIX only for now) +- age encryption support + +{{ range (gitHubKeys "twpayne") -}} +{{ .Key }} +{{ end -}} + +Config file: +- rename `sourceVCS` to `git` +- use `gpg.recipient` instead of `gpgRecipient` +- rename `genericSecret` to `secret` +- rename `homedir` to `homeDir` +- add `encryption` (currently `age` or `gpg`) +- apply `--ignore-encrypted` diff --git a/chezmoi2/internal/chezmoi/actualstateentry.go b/chezmoi2/internal/chezmoi/actualstateentry.go new file mode 100644 index 000000000000..8bb8cea12f5b --- /dev/null +++ b/chezmoi2/internal/chezmoi/actualstateentry.go @@ -0,0 +1,169 @@ +package chezmoi + +import ( + "os" +) + +// An ActualStateEntry represents the actual state of an entry in the +// filesystem. +type ActualStateEntry interface { + EntryState() (*EntryState, error) + Path() AbsPath + Remove(system System) error +} + +// A ActualStateAbsent represents the absence of an entry in the filesystem. +type ActualStateAbsent struct { + absPath AbsPath +} + +// A ActualStateDir represents the state of a directory in the filesystem. +type ActualStateDir struct { + absPath AbsPath + perm os.FileMode +} + +// A ActualStateFile represents the state of a file in the filesystem. +type ActualStateFile struct { + absPath AbsPath + perm os.FileMode + *lazyContents +} + +// A ActualStateSymlink represents the state of a symlink in the filesystem. +type ActualStateSymlink struct { + absPath AbsPath + *lazyLinkname +} + +// NewActualStateEntry returns a new ActualStateEntry populated with absPath +// from fs. +func NewActualStateEntry(system System, absPath AbsPath, info os.FileInfo, err error) (ActualStateEntry, error) { + if info == nil { + info, err = system.Lstat(absPath) + } + switch { + case os.IsNotExist(err): + return &ActualStateAbsent{ + absPath: absPath, + }, nil + case err != nil: + return nil, err + } + //nolint:exhaustive + switch info.Mode() & os.ModeType { + case 0: + return &ActualStateFile{ + absPath: absPath, + perm: info.Mode() & os.ModePerm, + lazyContents: &lazyContents{ + contentsFunc: func() ([]byte, error) { + return system.ReadFile(absPath) + }, + }, + }, nil + case os.ModeDir: + return &ActualStateDir{ + absPath: absPath, + perm: info.Mode() & os.ModePerm, + }, nil + case os.ModeSymlink: + return &ActualStateSymlink{ + absPath: absPath, + lazyLinkname: &lazyLinkname{ + linknameFunc: func() (string, error) { + linkname, err := system.Readlink(absPath) + if err != nil { + return "", err + } + return linkname, nil + }, + }, + }, nil + default: + return nil, &errUnsupportedFileType{ + absPath: absPath, + mode: info.Mode(), + } + } +} + +// EntryState returns d's entry state. +func (s *ActualStateAbsent) EntryState() (*EntryState, error) { + return &EntryState{ + Type: EntryStateTypeAbsent, + }, nil +} + +// Path returns d's path. +func (s *ActualStateAbsent) Path() AbsPath { + return s.absPath +} + +// Remove removes d. +func (s *ActualStateAbsent) Remove(system System) error { + return nil +} + +// EntryState returns d's entry state. +func (s *ActualStateDir) EntryState() (*EntryState, error) { + return &EntryState{ + Type: EntryStateTypeDir, + Mode: os.ModeDir | s.perm, + }, nil +} + +// Path returns d's path. +func (s *ActualStateDir) Path() AbsPath { + return s.absPath +} + +// Remove removes d. +func (s *ActualStateDir) Remove(system System) error { + return system.RemoveAll(s.absPath) +} + +// EntryState returns d's entry state. +func (s *ActualStateFile) EntryState() (*EntryState, error) { + contentsSHA256, err := s.ContentsSHA256() + if err != nil { + return nil, err + } + return &EntryState{ + Type: EntryStateTypeFile, + Mode: s.perm, + ContentsSHA256: hexBytes(contentsSHA256), + }, nil +} + +// Path returns d's path. +func (s *ActualStateFile) Path() AbsPath { + return s.absPath +} + +// Remove removes d. +func (s *ActualStateFile) Remove(system System) error { + return system.RemoveAll(s.absPath) +} + +// EntryState returns d's entry state. +func (s *ActualStateSymlink) EntryState() (*EntryState, error) { + contentsSHA256, err := s.LinknameSHA256() + if err != nil { + return nil, err + } + return &EntryState{ + Type: EntryStateTypeSymlink, + ContentsSHA256: hexBytes(contentsSHA256), + }, nil +} + +// Path returns d's path. +func (s *ActualStateSymlink) Path() AbsPath { + return s.absPath +} + +// Remove removes d. +func (s *ActualStateSymlink) Remove(system System) error { + return system.RemoveAll(s.absPath) +} diff --git a/chezmoi2/internal/chezmoi/ageencryption.go b/chezmoi2/internal/chezmoi/ageencryption.go new file mode 100644 index 000000000000..5a0ef6832b95 --- /dev/null +++ b/chezmoi2/internal/chezmoi/ageencryption.go @@ -0,0 +1,92 @@ +package chezmoi + +import ( + "bytes" + "os/exec" + + "github.com/rs/zerolog/log" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoilog" +) + +// An AGEEncryption uses age for encryption and decryption. See +// https://github.com/FiloSottile/age. +type AGEEncryption struct { + Command string + Args []string + Identity string + Identities []string + Recipient string + Recipients []string + RecipientsFile string + RecipientsFiles []string +} + +// Decrypt implements Encyrption.Decrypt. +func (t *AGEEncryption) Decrypt(ciphertext []byte) ([]byte, error) { + //nolint:gosec + cmd := exec.Command(t.Command, append(t.decryptArgs(), t.Args...)...) + cmd.Stdin = bytes.NewReader(ciphertext) + plaintext, err := chezmoilog.LogCmdOutput(log.Logger, cmd) + if err != nil { + return nil, err + } + return plaintext, nil +} + +// DecryptToFile implements Encryption.DecryptToFile. +func (t *AGEEncryption) DecryptToFile(filename string, ciphertext []byte) error { + //nolint:gosec + cmd := exec.Command(t.Command, append(append(t.decryptArgs(), "--output", filename), t.Args...)...) + cmd.Stdin = bytes.NewReader(ciphertext) + return chezmoilog.LogCmdRun(log.Logger, cmd) +} + +// Encrypt implements Encryption.Encrypt. +func (t *AGEEncryption) Encrypt(plaintext []byte) ([]byte, error) { + //nolint:gosec + cmd := exec.Command(t.Command, append(t.encryptArgs(), t.Args...)...) + cmd.Stdin = bytes.NewReader(plaintext) + ciphertext, err := chezmoilog.LogCmdOutput(log.Logger, cmd) + if err != nil { + return nil, err + } + return ciphertext, nil +} + +// EncryptFile implements Encryption.EncryptFile. +func (t *AGEEncryption) EncryptFile(filename string) ([]byte, error) { + //nolint:gosec + cmd := exec.Command(t.Command, append(append(t.encryptArgs(), t.Args...), filename)...) + return chezmoilog.LogCmdOutput(log.Logger, cmd) +} + +func (t *AGEEncryption) decryptArgs() []string { + args := make([]string, 0, 1+2*(1+len(t.Identities))) + args = append(args, "--decrypt") + if t.Identity != "" { + args = append(args, "--identity", t.Identity) + } + for _, identity := range t.Identities { + args = append(args, "--identity", identity) + } + return args +} + +func (t *AGEEncryption) encryptArgs() []string { + args := make([]string, 0, 1+2*(1+len(t.Recipients))+2*(1+len(t.RecipientsFiles))) + args = append(args, "--armor") + if t.Recipient != "" { + args = append(args, "--recipient", t.Recipient) + } + for _, recipient := range t.Recipients { + args = append(args, "--recipient", recipient) + } + if t.RecipientsFile != "" { + args = append(args, "--recipients-file", t.RecipientsFile) + } + for _, recipientsFile := range t.RecipientsFiles { + args = append(args, "--recipients-file", recipientsFile) + } + return args +} diff --git a/chezmoi2/internal/chezmoi/ageencryption_test.go b/chezmoi2/internal/chezmoi/ageencryption_test.go new file mode 100644 index 000000000000..5a91bd9e0900 --- /dev/null +++ b/chezmoi2/internal/chezmoi/ageencryption_test.go @@ -0,0 +1,38 @@ +package chezmoi + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestAGEEncryption(t *testing.T) { + command, err := exec.LookPath("age") + if errors.Is(err, exec.ErrNotFound) { + t.Skip("age not found in $PATH") + } + require.NoError(t, err) + + publicKey, privateKeyFile, err := chezmoitest.AGEGenerateKey("") + require.NoError(t, err) + defer func() { + assert.NoError(t, os.RemoveAll(filepath.Dir(privateKeyFile))) + }() + + ageEncryption := &AGEEncryption{ + Command: command, + Identity: privateKeyFile, + Recipient: publicKey, + } + + testEncryptionDecryptToFile(t, ageEncryption) + testEncryptionEncryptDecrypt(t, ageEncryption) + testEncryptionEncryptFile(t, ageEncryption) +} diff --git a/chezmoi2/internal/chezmoi/attr.go b/chezmoi2/internal/chezmoi/attr.go new file mode 100644 index 000000000000..3ad31cc00b5f --- /dev/null +++ b/chezmoi2/internal/chezmoi/attr.go @@ -0,0 +1,240 @@ +package chezmoi + +import ( + "os" + "strings" +) + +// A SourceFileTargetType is a the type of a target represented by a file in the +// source state. A file in the source state can represent a file, script, or +// symlink in the target state. +type SourceFileTargetType int + +// Source file types. +const ( + SourceFileTypeFile SourceFileTargetType = iota + SourceFileTypePresent + SourceFileTypeScript + SourceFileTypeSymlink +) + +// DirAttr holds attributes parsed from a source directory name. +type DirAttr struct { + TargetName string + Exact bool + Private bool +} + +// A FileAttr holds attributes parsed from a source file name. +type FileAttr struct { + TargetName string + Type SourceFileTargetType + Empty bool + Encrypted bool + Executable bool + Once bool + Order int + Private bool + Template bool +} + +// parseDirAttr parses a single directory name in the source state. +func parseDirAttr(sourceName string) DirAttr { + var ( + name = sourceName + exact = false + private = false + ) + if strings.HasPrefix(name, exactPrefix) { + name = mustTrimPrefix(name, exactPrefix) + exact = true + } + if strings.HasPrefix(name, privatePrefix) { + name = mustTrimPrefix(name, privatePrefix) + private = true + } + if strings.HasPrefix(name, dotPrefix) { + name = "." + mustTrimPrefix(name, dotPrefix) + } + return DirAttr{ + TargetName: name, + Exact: exact, + Private: private, + } +} + +// SourceName returns da's source name. +func (da DirAttr) SourceName() string { + sourceName := "" + if da.Exact { + sourceName += exactPrefix + } + if da.Private { + sourceName += privatePrefix + } + if strings.HasPrefix(da.TargetName, ".") { + sourceName += dotPrefix + mustTrimPrefix(da.TargetName, ".") + } else { + sourceName += da.TargetName + } + return sourceName +} + +// perm returns da's file mode. +func (da DirAttr) perm() os.FileMode { + perm := os.FileMode(0o777) + if da.Private { + perm &^= 0o77 + } + return perm +} + +// parseFileAttr parses a source file name in the source state. +func parseFileAttr(sourceName string) FileAttr { + var ( + sourceFileType = SourceFileTypeFile + name = sourceName + empty = false + encrypted = false + executable = false + once = false + private = false + template = false + order = 0 + ) + switch { + case strings.HasPrefix(name, existsPrefix): + sourceFileType = SourceFileTypePresent + name = mustTrimPrefix(name, existsPrefix) + if strings.HasPrefix(name, encryptedPrefix) { + name = mustTrimPrefix(name, encryptedPrefix) + encrypted = true + } + if strings.HasPrefix(name, privatePrefix) { + name = mustTrimPrefix(name, privatePrefix) + private = true + } + if strings.HasPrefix(name, executablePrefix) { + name = mustTrimPrefix(name, executablePrefix) + executable = true + } + case strings.HasPrefix(name, runPrefix): + sourceFileType = SourceFileTypeScript + name = mustTrimPrefix(name, runPrefix) + switch { + case strings.HasPrefix(name, firstPrefix): + name = mustTrimPrefix(name, firstPrefix) + order = -1 + case strings.HasPrefix(name, lastPrefix): + name = mustTrimPrefix(name, lastPrefix) + order = 1 + } + if strings.HasPrefix(name, oncePrefix) { + name = mustTrimPrefix(name, oncePrefix) + once = true + } + case strings.HasPrefix(name, symlinkPrefix): + sourceFileType = SourceFileTypeSymlink + name = mustTrimPrefix(name, symlinkPrefix) + default: + if strings.HasPrefix(name, encryptedPrefix) { + name = mustTrimPrefix(name, encryptedPrefix) + encrypted = true + } + if strings.HasPrefix(name, privatePrefix) { + name = mustTrimPrefix(name, privatePrefix) + private = true + } + if strings.HasPrefix(name, emptyPrefix) { + name = mustTrimPrefix(name, emptyPrefix) + empty = true + } + if strings.HasPrefix(name, executablePrefix) { + name = mustTrimPrefix(name, executablePrefix) + executable = true + } + } + if strings.HasPrefix(name, dotPrefix) { + name = "." + mustTrimPrefix(name, dotPrefix) + } + if strings.HasSuffix(name, TemplateSuffix) { + name = mustTrimSuffix(name, TemplateSuffix) + template = true + } + return FileAttr{ + TargetName: name, + Type: sourceFileType, + Empty: empty, + Encrypted: encrypted, + Executable: executable, + Once: once, + Private: private, + Template: template, + Order: order, + } +} + +// SourceName returns fa's source name. +func (fa FileAttr) SourceName() string { + sourceName := "" + switch fa.Type { + case SourceFileTypeFile: + if fa.Encrypted { + sourceName += encryptedPrefix + } + if fa.Private { + sourceName += privatePrefix + } + if fa.Empty { + sourceName += emptyPrefix + } + if fa.Executable { + sourceName += executablePrefix + } + case SourceFileTypePresent: + sourceName = existsPrefix + if fa.Encrypted { + sourceName += encryptedPrefix + } + if fa.Private { + sourceName += privatePrefix + } + if fa.Executable { + sourceName += executablePrefix + } + case SourceFileTypeScript: + sourceName = runPrefix + switch fa.Order { + case -1: + sourceName += firstPrefix + case 1: + sourceName += lastPrefix + } + if fa.Once { + sourceName += oncePrefix + } + case SourceFileTypeSymlink: + sourceName = symlinkPrefix + } + if strings.HasPrefix(fa.TargetName, ".") { + sourceName += dotPrefix + mustTrimPrefix(fa.TargetName, ".") + } else { + sourceName += fa.TargetName + } + if fa.Template { + sourceName += TemplateSuffix + } + return sourceName +} + +// perm returns fa's permissions. +func (fa FileAttr) perm() os.FileMode { + perm := os.FileMode(0o666) + if fa.Executable { + perm |= 0o111 + } + if fa.Private { + perm &^= 0o77 + } + return perm +} diff --git a/chezmoi2/internal/chezmoi/attr_test.go b/chezmoi2/internal/chezmoi/attr_test.go new file mode 100644 index 000000000000..56f5f5c9258c --- /dev/null +++ b/chezmoi2/internal/chezmoi/attr_test.go @@ -0,0 +1,116 @@ +package chezmoi + +import ( + "testing" + + "github.com/muesli/combinator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDirAttr(t *testing.T) { + testData := struct { + TargetName []string + Exact []bool + Private []bool + }{ + TargetName: []string{ + ".dir", + "dir.tmpl", + "dir", + "empty_dir", + "encrypted_dir", + "executable_dir", + "once_dir", + "run_dir", + "run_once_dir", + "symlink_dir", + }, + Exact: []bool{false, true}, + Private: []bool{false, true}, + } + var das []DirAttr + require.NoError(t, combinator.Generate(&das, testData)) + for _, da := range das { + actualSourceName := da.SourceName() + actualDA := parseDirAttr(actualSourceName) + assert.Equal(t, da, actualDA) + assert.Equal(t, actualSourceName, actualDA.SourceName()) + } +} + +func TestFileAttr(t *testing.T) { + var fas []FileAttr + require.NoError(t, combinator.Generate(&fas, struct { + Type SourceFileTargetType + TargetName []string + Empty []bool + Encrypted []bool + Executable []bool + Private []bool + Template []bool + }{ + Type: SourceFileTypeFile, + TargetName: []string{ + ".name", + "exact_name", + "name", + }, + Empty: []bool{false, true}, + Encrypted: []bool{false, true}, + Executable: []bool{false, true}, + Private: []bool{false, true}, + Template: []bool{false, true}, + })) + require.NoError(t, combinator.Generate(&fas, struct { + Type SourceFileTargetType + TargetName []string + Encrypted []bool + Executable []bool + Private []bool + Template []bool + }{ + Type: SourceFileTypePresent, + TargetName: []string{ + ".name", + "exact_name", + "name", + }, + Encrypted: []bool{false, true}, + Executable: []bool{false, true}, + Private: []bool{false, true}, + Template: []bool{false, true}, + })) + require.NoError(t, combinator.Generate(&fas, struct { + Type SourceFileTargetType + TargetName []string + Once []bool + Order []int + }{ + Type: SourceFileTypeScript, + TargetName: []string{ + ".name", + "exact_name", + "name", + }, + Once: []bool{false, true}, + Order: []int{-1, 0, 1}, + })) + require.NoError(t, combinator.Generate(&fas, struct { + Type SourceFileTargetType + TargetName []string + }{ + Type: SourceFileTypeSymlink, + TargetName: []string{ + ".name", + "exact_name", + "name", + }, + })) + for _, fa := range fas { + actualSourceName := fa.SourceName() + actualFA := parseFileAttr(actualSourceName) + assert.Equal(t, fa, actualFA) + assert.Equal(t, actualSourceName, actualFA.SourceName()) + } +} diff --git a/chezmoi2/internal/chezmoi/autotemplate.go b/chezmoi2/internal/chezmoi/autotemplate.go new file mode 100644 index 000000000000..4f0e70d32d17 --- /dev/null +++ b/chezmoi2/internal/chezmoi/autotemplate.go @@ -0,0 +1,100 @@ +package chezmoi + +import ( + "sort" + "strings" +) + +// A templateVariable is a template variable. It is used instead of a +// map[string]string so that we can control order. +type templateVariable struct { + name string + value string +} + +// byValueLength implements sort.Interface for a slice of templateVariables, +// sorting by value length. +type byValueLength []templateVariable + +func (b byValueLength) Len() int { return len(b) } +func (b byValueLength) Less(i, j int) bool { + switch { + case len(b[i].value) < len(b[j].value): // First sort by value length. + return true + case len(b[i].value) == len(b[j].value): + return b[i].name > b[j].name // Second sort by value name. + default: + return false + } +} +func (b byValueLength) Swap(i, j int) { b[i], b[j] = b[j], b[i] } + +func autoTemplate(contents []byte, data map[string]interface{}) []byte { + // This naive approach will generate incorrect templates if the variable + // names match variable values. The algorithm here is probably O(N^2), we + // can do better. + variables := extractVariables(data) + sort.Sort(sort.Reverse(byValueLength(variables))) + contentsStr := string(contents) + for _, variable := range variables { + if variable.value == "" { + continue + } + index := strings.Index(contentsStr, variable.value) + for index != -1 && index != len(contentsStr) { + if !inWord(contentsStr, index) && !inWord(contentsStr, index+len(variable.value)) { + // Replace variable.value which is on word boundaries at both + // ends. + replacement := "{{ ." + variable.name + " }}" + contentsStr = contentsStr[:index] + replacement + contentsStr[index+len(variable.value):] + index += len(replacement) + } else { + // Otherwise, keep looking. Consume at least one byte so we make + // progress. + index++ + } + // Look for the next occurrence of variable.value. + j := strings.Index(contentsStr[index:], variable.value) + if j == -1 { + // No more occurrences found, so terminate the loop. + break + } else { + // Advance to the next occurrence. + index += j + } + } + } + return []byte(contentsStr) +} + +// extractVariables extracts all template variables from data. +func extractVariables(data map[string]interface{}) []templateVariable { + return extractVariablesHelper(nil /* variables */, nil /* parent */, data) +} + +// extractVariablesHelper appends all template variables in data to variables +// and returns variables. data is assumed to be rooted at parent. +func extractVariablesHelper(variables []templateVariable, parent []string, data map[string]interface{}) []templateVariable { + for name, value := range data { + switch value := value.(type) { + case string: + variables = append(variables, templateVariable{ + name: strings.Join(append(parent, name), "."), + value: value, + }) + case map[string]interface{}: + variables = extractVariablesHelper(variables, append(parent, name), value) + } + } + return variables +} + +// inWord returns true if splitting s at position i would split a word. +func inWord(s string, i int) bool { + return i > 0 && i < len(s) && isWord(s[i-1]) && isWord(s[i]) +} + +// isWord returns true if b is a word byte. +func isWord(b byte) bool { + return '0' <= b && b <= '9' || 'A' <= b && b <= 'Z' || 'a' <= b && b <= 'z' +} diff --git a/chezmoi2/internal/chezmoi/autotemplate_test.go b/chezmoi2/internal/chezmoi/autotemplate_test.go new file mode 100644 index 000000000000..ca84a9ac1c1a --- /dev/null +++ b/chezmoi2/internal/chezmoi/autotemplate_test.go @@ -0,0 +1,170 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAutoTemplate(t *testing.T) { + for _, tc := range []struct { + name string + contentsStr string + data map[string]interface{} + expected string + }{ + { + name: "simple", + contentsStr: "email = you@example.com\n", + data: map[string]interface{}{ + "email": "you@example.com", + }, + expected: "email = {{ .email }}\n", + }, + { + name: "longest_first", + contentsStr: "name = John Smith\nfirstName = John\n", + data: map[string]interface{}{ + "name": "John Smith", + "firstName": "John", + }, + expected: "" + + "name = {{ .name }}\n" + + "firstName = {{ .firstName }}\n", + }, + { + name: "alphabetical_first", + contentsStr: "name = John Smith\n", + data: map[string]interface{}{ + "alpha": "John Smith", + "beta": "John Smith", + "gamma": "John Smith", + }, + expected: "name = {{ .alpha }}\n", + }, + { + name: "nested_values", + contentsStr: "email = you@example.com\n", + data: map[string]interface{}{ + "personal": map[string]interface{}{ + "email": "you@example.com", + }, + }, + expected: "email = {{ .personal.email }}\n", + }, + { + name: "only_replace_words", + contentsStr: "darwinian evolution", + data: map[string]interface{}{ + "os": "darwin", + }, + expected: "darwinian evolution", // not "{{ .os }}ian evolution" + }, + { + name: "longest_match_first", + contentsStr: "/home/user", + data: map[string]interface{}{ + "homeDir": "/home/user", + }, + expected: "{{ .homeDir }}", + }, + { + name: "longest_match_first_prefix", + contentsStr: "HOME=/home/user", + data: map[string]interface{}{ + "homeDir": "/home/user", + }, + expected: "HOME={{ .homeDir }}", + }, + { + name: "longest_match_first_suffix", + contentsStr: "/home/user/something", + data: map[string]interface{}{ + "homeDir": "/home/user", + }, + expected: "{{ .homeDir }}/something", + }, + { + name: "longest_match_first_prefix_and_suffix", + contentsStr: "HOME=/home/user/something", + data: map[string]interface{}{ + "homeDir": "/home/user", + }, + expected: "HOME={{ .homeDir }}/something", + }, + { + name: "words_only", + contentsStr: "aaa aa a aa aaa aa a aa aaa", + data: map[string]interface{}{ + "alpha": "a", + }, + expected: "aaa aa {{ .alpha }} aa aaa aa {{ .alpha }} aa aaa", + }, + { + name: "words_only_2", + contentsStr: "aaa aa a aa aaa aa a aa aaa", + data: map[string]interface{}{ + "alpha": "aa", + }, + expected: "aaa {{ .alpha }} a {{ .alpha }} aaa {{ .alpha }} a {{ .alpha }} aaa", + }, + { + name: "words_only_3", + contentsStr: "aaa aa a aa aaa aa a aa aaa", + data: map[string]interface{}{ + "alpha": "aaa", + }, + expected: "{{ .alpha }} aa a aa {{ .alpha }} aa a aa {{ .alpha }}", + }, + { + name: "skip_empty", + contentsStr: "a", + data: map[string]interface{}{ + "empty": "", + }, + expected: "a", + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, string(autoTemplate([]byte(tc.contentsStr), tc.data))) + }) + } +} + +func TestInWord(t *testing.T) { + for _, tc := range []struct { + s string + i int + expected bool + }{ + {s: "", i: 0, expected: false}, + {s: "a", i: 0, expected: false}, + {s: "a", i: 1, expected: false}, + {s: "ab", i: 0, expected: false}, + {s: "ab", i: 1, expected: true}, + {s: "ab", i: 2, expected: false}, + {s: "abc", i: 0, expected: false}, + {s: "abc", i: 1, expected: true}, + {s: "abc", i: 2, expected: true}, + {s: "abc", i: 3, expected: false}, + {s: " abc ", i: 0, expected: false}, + {s: " abc ", i: 1, expected: false}, + {s: " abc ", i: 2, expected: true}, + {s: " abc ", i: 3, expected: true}, + {s: " abc ", i: 4, expected: false}, + {s: " abc ", i: 5, expected: false}, + {s: "/home/user", i: 0, expected: false}, + {s: "/home/user", i: 1, expected: false}, + {s: "/home/user", i: 2, expected: true}, + {s: "/home/user", i: 3, expected: true}, + {s: "/home/user", i: 4, expected: true}, + {s: "/home/user", i: 5, expected: false}, + {s: "/home/user", i: 6, expected: false}, + {s: "/home/user", i: 7, expected: true}, + {s: "/home/user", i: 8, expected: true}, + {s: "/home/user", i: 9, expected: true}, + {s: "/home/user", i: 10, expected: false}, + } { + assert.Equal(t, tc.expected, inWord(tc.s, tc.i)) + } +} diff --git a/chezmoi2/internal/chezmoi/boltpersistentstate.go b/chezmoi2/internal/chezmoi/boltpersistentstate.go new file mode 100644 index 000000000000..70bf2c5f190d --- /dev/null +++ b/chezmoi2/internal/chezmoi/boltpersistentstate.go @@ -0,0 +1,145 @@ +package chezmoi + +import ( + "os" + "time" + + "go.etcd.io/bbolt" +) + +// A BoltPersistentStateMode is a mode for opening a PersistentState. +type BoltPersistentStateMode int + +// PersistentStateModes. +const ( + BoltPersistentStateReadOnly BoltPersistentStateMode = iota + BoltPersistentStateReadWrite +) + +// A BoltPersistentState is a state persisted with bolt. +type BoltPersistentState struct { + db *bbolt.DB +} + +// NewBoltPersistentState returns a new BoltPersistentState. +func NewBoltPersistentState(system System, path AbsPath, mode BoltPersistentStateMode) (*BoltPersistentState, error) { + if _, err := system.Stat(path); os.IsNotExist(err) { + if mode == BoltPersistentStateReadOnly { + return &BoltPersistentState{}, nil + } + if err := MkdirAll(system, path.Dir(), 0o777); err != nil { + return nil, err + } + } + options := bbolt.Options{ + OpenFile: func(name string, flag int, perm os.FileMode) (*os.File, error) { + rawPath, err := system.RawPath(AbsPath(name)) + if err != nil { + return nil, err + } + return os.OpenFile(string(rawPath), flag, perm) + }, + ReadOnly: mode == BoltPersistentStateReadOnly, + Timeout: time.Second, + } + db, err := bbolt.Open(string(path), 0o600, &options) + if err != nil { + return nil, err + } + return &BoltPersistentState{ + db: db, + }, nil +} + +// Close closes b. +func (b *BoltPersistentState) Close() error { + if b.db == nil { + return nil + } + return b.db.Close() +} + +// CopyTo copies b to p. +func (b *BoltPersistentState) CopyTo(p PersistentState) error { + if b.db == nil { + return nil + } + + return b.db.View(func(tx *bbolt.Tx) error { + return tx.ForEach(func(bucket []byte, b *bbolt.Bucket) error { + return b.ForEach(func(key, value []byte) error { + return p.Set(copyByteSlice(bucket), copyByteSlice(key), copyByteSlice(value)) + }) + }) + }) +} + +// Delete deletes the value associate with key in bucket. If bucket or key does +// not exist then Delete does nothing. +func (b *BoltPersistentState) Delete(bucket, key []byte) error { + return b.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket(bucket) + if b == nil { + return nil + } + return b.Delete(key) + }) +} + +// ForEach calls fn for each key, value pair in bucket. +func (b *BoltPersistentState) ForEach(bucket []byte, fn func(k, v []byte) error) error { + if b.db == nil { + return nil + } + + return b.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket(bucket) + if b == nil { + return nil + } + return b.ForEach(func(k, v []byte) error { + return fn(copyByteSlice(k), copyByteSlice(v)) + }) + }) +} + +// Get returns the value associated with key in bucket. +func (b *BoltPersistentState) Get(bucket, key []byte) ([]byte, error) { + if b.db == nil { + return nil, nil + } + + var value []byte + if err := b.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket(bucket) + if b == nil { + return nil + } + value = copyByteSlice(b.Get(key)) + return nil + }); err != nil { + return nil, err + } + return value, nil +} + +// Set sets the value associated with key in bucket. bucket will be created if +// it does not already exist. +func (b *BoltPersistentState) Set(bucket, key, value []byte) error { + return b.db.Update(func(tx *bbolt.Tx) error { + b, err := tx.CreateBucketIfNotExists(bucket) + if err != nil { + return err + } + return b.Put(key, value) + }) +} + +func copyByteSlice(value []byte) []byte { + if value == nil { + return nil + } + result := make([]byte, len(value)) + copy(result, value) + return result +} diff --git a/chezmoi2/internal/chezmoi/boltpersistentstate_test.go b/chezmoi2/internal/chezmoi/boltpersistentstate_test.go new file mode 100644 index 000000000000..bf877ad30a1c --- /dev/null +++ b/chezmoi2/internal/chezmoi/boltpersistentstate_test.go @@ -0,0 +1,144 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +var _ PersistentState = &BoltPersistentState{} + +func TestBoltPersistentState(t *testing.T) { + chezmoitest.WithTestFS(t, nil, func(fs vfs.FS) { + var ( + s = NewRealSystem(fs) + path = AbsPath("/home/user/.config/chezmoi/chezmoistate.boltdb") + bucket = []byte("bucket") + key = []byte("key") + value = []byte("value") + ) + + b1, err := NewBoltPersistentState(s, path, BoltPersistentStateReadWrite) + require.NoError(t, err) + vfst.RunTests(t, fs, "", + vfst.TestPath(string(path), + vfst.TestModeIsRegular, + ), + ) + + actualValue, err := b1.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, []byte(nil), actualValue) + + assert.NoError(t, b1.Set(bucket, key, value)) + actualValue, err = b1.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value, actualValue) + + visited := false + require.NoError(t, b1.ForEach(bucket, func(k, v []byte) error { + visited = true + assert.Equal(t, key, k) + assert.Equal(t, value, v) + return nil + })) + require.True(t, visited) + + require.NoError(t, b1.Close()) + + b2, err := NewBoltPersistentState(s, path, BoltPersistentStateReadWrite) + require.NoError(t, err) + + require.NoError(t, b2.Delete(bucket, key)) + + actualValue, err = b2.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, []byte(nil), actualValue) + }) +} + +func TestBoltPersistentStateMock(t *testing.T) { + chezmoitest.WithTestFS(t, nil, func(fs vfs.FS) { + var ( + s = NewRealSystem(fs) + path = AbsPath("/home/user/.config/chezmoi/chezmoistate.boltdb") + bucket = []byte("bucket") + key = []byte("key") + value1 = []byte("value1") + value2 = []byte("value2") + ) + + b, err := NewBoltPersistentState(s, path, BoltPersistentStateReadWrite) + require.NoError(t, err) + require.NoError(t, b.Set(bucket, key, value1)) + + m := NewMockPersistentState() + require.NoError(t, b.CopyTo(m), err) + + actualValue, err := m.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value1, actualValue) + + require.NoError(t, m.Set(bucket, key, value2)) + actualValue, err = m.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value2, actualValue) + actualValue, err = b.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value1, actualValue) + + require.NoError(t, m.Delete(bucket, key)) + actualValue, err = m.Get(bucket, key) + require.NoError(t, err) + assert.Nil(t, actualValue) + actualValue, err = b.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value1, actualValue) + + require.NoError(t, b.Close()) + }) +} + +func TestBoltPersistentStateReadOnly(t *testing.T) { + chezmoitest.WithTestFS(t, nil, func(fs vfs.FS) { + var ( + s = NewRealSystem(fs) + path = AbsPath("/home/user/.config/chezmoi/chezmoistate.boltdb") + bucket = []byte("bucket") + key = []byte("key") + value = []byte("value") + ) + + b1, err := NewBoltPersistentState(s, path, BoltPersistentStateReadWrite) + require.NoError(t, err) + require.NoError(t, b1.Set(bucket, key, value)) + require.NoError(t, b1.Close()) + + b2, err := NewBoltPersistentState(s, path, BoltPersistentStateReadOnly) + require.NoError(t, err) + defer b2.Close() + + b3, err := NewBoltPersistentState(s, path, BoltPersistentStateReadOnly) + require.NoError(t, err) + defer b3.Close() + + actualValueB, err := b2.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value, actualValueB) + + actualValueC, err := b3.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value, actualValueC) + + assert.Error(t, b2.Set(bucket, key, value)) + assert.Error(t, b3.Set(bucket, key, value)) + + require.NoError(t, b2.Close()) + require.NoError(t, b3.Close()) + }) +} diff --git a/chezmoi2/internal/chezmoi/chezmoi.go b/chezmoi2/internal/chezmoi/chezmoi.go new file mode 100644 index 000000000000..4076ded72343 --- /dev/null +++ b/chezmoi2/internal/chezmoi/chezmoi.go @@ -0,0 +1,152 @@ +package chezmoi + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" +) + +var ( + // DefaultTemplateOptions are the default template options. + DefaultTemplateOptions = []string{"missingkey=error"} + + // Skip indicates that entry should be skipped. + Skip = filepath.SkipDir +) + +// Suffixes and prefixes. +const ( + ignorePrefix = "." + dotPrefix = "dot_" + emptyPrefix = "empty_" + encryptedPrefix = "encrypted_" + exactPrefix = "exact_" + executablePrefix = "executable_" + existsPrefix = "exists_" + firstPrefix = "first_" + lastPrefix = "last_" + oncePrefix = "once_" + privatePrefix = "private_" + runPrefix = "run_" + symlinkPrefix = "symlink_" + TemplateSuffix = ".tmpl" +) + +// Special file names. +const ( + Prefix = ".chezmoi" + + dataName = Prefix + "data" + ignoreName = Prefix + "ignore" + removeName = Prefix + "remove" + templatesDirName = Prefix + "templates" + versionName = Prefix + "version" +) + +var knownPrefixedFiles = map[string]bool{ + Prefix + ".json" + TemplateSuffix: true, + Prefix + ".toml" + TemplateSuffix: true, + Prefix + ".yaml" + TemplateSuffix: true, + dataName: true, + ignoreName: true, + removeName: true, + versionName: true, +} + +var modeTypeNames = map[os.FileMode]string{ + 0: "file", + os.ModeDir: "dir", + os.ModeSymlink: "symlink", + os.ModeNamedPipe: "named pipe", + os.ModeSocket: "socket", + os.ModeDevice: "device", + os.ModeCharDevice: "char device", +} + +type errDuplicateTarget struct { + targetRelPath RelPath + sourceRelPaths SourceRelPaths +} + +func (e *errDuplicateTarget) Error() string { + sourceRelPathStrs := make([]string, 0, len(e.sourceRelPaths)) + for _, sourceRelPath := range e.sourceRelPaths { + sourceRelPathStrs = append(sourceRelPathStrs, sourceRelPath.String()) + } + return fmt.Sprintf("%s: duplicate source state entries (%s)", e.targetRelPath, strings.Join(sourceRelPathStrs, ", ")) +} + +type errNotInAbsDir struct { + pathAbsPath AbsPath + dirAbsPath AbsPath +} + +func (e *errNotInAbsDir) Error() string { + return fmt.Sprintf("%s: not in %s", e.pathAbsPath, e.dirAbsPath) +} + +type errNotInRelDir struct { + pathRelPath RelPath + dirRelPath RelPath +} + +func (e *errNotInRelDir) Error() string { + return fmt.Sprintf("%s: not in %s", e.pathRelPath, e.dirRelPath) +} + +type errUnsupportedFileType struct { + absPath AbsPath + mode os.FileMode +} + +func (e *errUnsupportedFileType) Error() string { + return fmt.Sprintf("%s: unsupported file type %s", e.absPath, modeTypeName(e.mode)) +} + +// SuspiciousSourceDirEntry returns true if base is a suspicous dir entry. +func SuspiciousSourceDirEntry(base string, info os.FileInfo) bool { + //nolint:exhaustive + switch info.Mode() & os.ModeType { + case 0: + return strings.HasPrefix(base, Prefix) && !knownPrefixedFiles[base] + case os.ModeDir: + return strings.HasPrefix(base, Prefix) && base != templatesDirName + case os.ModeSymlink: + return strings.HasPrefix(base, Prefix) + default: + return true + } +} + +// isEmpty returns true if data is empty after trimming whitespace from both +// ends. +func isEmpty(data []byte) bool { + return len(bytes.TrimSpace(data)) == 0 +} + +func modeTypeName(mode os.FileMode) string { + if name, ok := modeTypeNames[mode&os.ModeType]; ok { + return name + } + return fmt.Sprintf("0o%o: unknown type", mode&os.ModeType) +} + +// mustTrimPrefix is like strings.TrimPrefix but panics if s is not prefixed by +// prefix. +func mustTrimPrefix(s, prefix string) string { + if !strings.HasPrefix(s, prefix) { + panic(fmt.Sprintf("%s: not prefixed by %s", s, prefix)) + } + return s[len(prefix):] +} + +// mustTrimSuffix is like strings.TrimSuffix but panics if s is not suffixed by +// suffix. +func mustTrimSuffix(s, suffix string) string { + if !strings.HasSuffix(s, suffix) { + panic(fmt.Sprintf("%s: not suffixed by %s", s, suffix)) + } + return s[:len(s)-len(suffix)] +} diff --git a/chezmoi2/internal/chezmoi/chezmoi_test.go b/chezmoi2/internal/chezmoi/chezmoi_test.go new file mode 100644 index 000000000000..05270a1d6762 --- /dev/null +++ b/chezmoi2/internal/chezmoi/chezmoi_test.go @@ -0,0 +1,18 @@ +package chezmoi + +import ( + "os" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/rs/zerolog/pkgerrors" +) + +//nolint:gochecknoinits +func init() { + log.Logger = log.Output(zerolog.ConsoleWriter{ + Out: os.Stderr, + NoColor: true, + }) + zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack +} diff --git a/chezmoi2/internal/chezmoi/chezmoi_unix.go b/chezmoi2/internal/chezmoi/chezmoi_unix.go new file mode 100644 index 000000000000..bcd46a0fe66e --- /dev/null +++ b/chezmoi2/internal/chezmoi/chezmoi_unix.go @@ -0,0 +1,93 @@ +// +build !windows + +package chezmoi + +import ( + "bufio" + "bytes" + "net" + "os" + "regexp" + "strings" + "syscall" + + vfs "github.com/twpayne/go-vfs" +) + +var ( + umask os.FileMode + whitespaceRx = regexp.MustCompile(`\s+`) +) + +func init() { + umask = os.FileMode(syscall.Umask(0)) + syscall.Umask(int(umask)) +} + +// FQDNHostname returns the FQDN hostname. +func FQDNHostname(fs vfs.FS) (string, error) { + if fqdnHostname, err := etcHostsFQDNHostname(fs); err == nil && fqdnHostname != "" { + return fqdnHostname, nil + } + return lookupAddrFQDNHostname() +} + +// GetUmask returns the umask. +func GetUmask() os.FileMode { + return umask +} + +// SetUmask sets the umask. +func SetUmask(newUmask os.FileMode) { + umask = newUmask + syscall.Umask(int(umask)) +} + +// etcHostsFQDNHostname returns the FQDN hostname from parsing /etc/hosts. +func etcHostsFQDNHostname(fs vfs.FS) (string, error) { + etcHostsContents, err := fs.ReadFile("/etc/hosts") + if err != nil { + return "", err + } + s := bufio.NewScanner(bytes.NewReader(etcHostsContents)) + for s.Scan() { + text := s.Text() + text = strings.TrimSpace(text) + if index := strings.IndexByte(text, '#'); index != -1 { + text = text[:index] + } + fields := whitespaceRx.Split(text, -1) + if len(fields) >= 2 && fields[0] == "127.0.1.1" { + return fields[1], nil + } + } + return "", s.Err() +} + +// isExecutable returns if info is executable. +func isExecutable(info os.FileInfo) bool { + return info.Mode().Perm()&0o111 != 0 +} + +// isPrivate returns if info is private. +func isPrivate(info os.FileInfo) bool { + return info.Mode().Perm()&0o77 == 0 +} + +// lookupAddrFQDNHostname returns the FQDN hostname by doing a reverse lookup of +// 127.0.1.1. +func lookupAddrFQDNHostname() (string, error) { + names, err := net.LookupAddr("127.0.1.1") + if err != nil { + return "", err + } + if len(names) == 0 { + return "", nil + } + return strings.TrimSuffix(names[0], "."), nil +} + +// umaskPermEqual returns if two permissions are equal after applying umask. +func umaskPermEqual(perm1, perm2, umask os.FileMode) bool { + return perm1&^umask == perm2&^umask +} diff --git a/chezmoi2/internal/chezmoi/chezmoi_unix_test.go b/chezmoi2/internal/chezmoi/chezmoi_unix_test.go new file mode 100644 index 000000000000..07102c7e9c1e --- /dev/null +++ b/chezmoi2/internal/chezmoi/chezmoi_unix_test.go @@ -0,0 +1,70 @@ +// +build !windows + +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestEtcHostsFQDNHostname(t *testing.T) { + for _, tc := range []struct { + name string + etcHostsContentsStr string + expected string + }{ + { + name: "empty", + }, + { + name: "linux_example", + etcHostsContentsStr: chezmoitest.JoinLines( + `# The following lines are desirable for IPv4 capable hosts`, + `127.0.0.1 localhost`, + ``, + `# 127.0.1.1 is often used for the FQDN of the machine`, + `127.0.1.1 thishost.mydomain.org thishost`, + `192.168.1.10 foo.mydomain.org foo`, + `192.168.1.13 bar.mydomain.org bar`, + `146.82.138.7 master.debian.org master`, + `209.237.226.90 www.opensource.org`, + ``, + `# The following lines are desirable for IPv6 capable hosts`, + `::1 localhost ip6-localhost ip6-loopback`, + `ff02::1 ip6-allnodes`, + `ff02::2 ip6-allrouters`, + ), + expected: "thishost.mydomain.org", + }, + { + name: "whitespace_and_comments", + etcHostsContentsStr: chezmoitest.JoinLines( + " \t127.0.1.1 \tthishost.mydomain.org# comment", + ), + expected: "thishost.mydomain.org", + }, + { + name: "missing_canonical_hostname", + etcHostsContentsStr: chezmoitest.JoinLines( + `127.0.1.1`, + `127.0.1.1 thishost.mydomain.org`, + ), + expected: "thishost.mydomain.org", + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]interface{}{ + "/etc/hosts": tc.etcHostsContentsStr, + }, func(fs vfs.FS) { + actual, err := etcHostsFQDNHostname(fs) + require.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + }) + } +} diff --git a/chezmoi2/internal/chezmoi/chezmoi_windows.go b/chezmoi2/internal/chezmoi/chezmoi_windows.go new file mode 100644 index 000000000000..d3a4f56baa01 --- /dev/null +++ b/chezmoi2/internal/chezmoi/chezmoi_windows.go @@ -0,0 +1,40 @@ +package chezmoi + +import ( + "os" + + vfs "github.com/twpayne/go-vfs" +) + +// FQDNHostname does nothing on Windows. +func FQDNHostname(fs vfs.FS) (string, error) { + // LATER find out how to determine the FQDN hostname on Windows + return "", nil +} + +// GetUmask returns the umask. +func GetUmask() os.FileMode { + return os.ModePerm +} + +// SetUmask sets the umask. +func SetUmask(umask os.FileMode) {} + +// isExecutable returns false on Windows. +func isExecutable(info os.FileInfo) bool { + return false +} + +// isPrivate returns false on Windows. +func isPrivate(info os.FileInfo) bool { + return false +} + +func isSlash(c uint8) bool { + return c == '\\' || c == '/' +} + +// umaskPermEqual returns true on Windows. +func umaskPermEqual(perm1 os.FileMode, perm2 os.FileMode, umask os.FileMode) bool { + return true +} diff --git a/chezmoi2/internal/chezmoi/data_linux.go b/chezmoi2/internal/chezmoi/data_linux.go new file mode 100644 index 000000000000..18bd512a4dbe --- /dev/null +++ b/chezmoi2/internal/chezmoi/data_linux.go @@ -0,0 +1,106 @@ +package chezmoi + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "unicode" + + "github.com/twpayne/go-vfs" +) + +// KernelInfo returns the kernel information parsed from /proc/sys/kernel. +func KernelInfo(fs vfs.FS) (map[string]string, error) { + const procSysKernel = "/proc/sys/kernel" + + info, err := fs.Stat(procSysKernel) + switch { + case os.IsNotExist(err): + return nil, nil + case os.IsPermission(err): + return nil, nil + case err != nil: + return nil, err + case !info.Mode().IsDir(): + return nil, nil + } + + kernelInfo := make(map[string]string) + for _, filename := range []string{ + "osrelease", + "ostype", + "version", + } { + data, err := fs.ReadFile(filepath.Join(procSysKernel, filename)) + switch { + case os.IsNotExist(err): + continue + case os.IsPermission(err): + continue + case err != nil: + return nil, err + } + kernelInfo[filename] = string(bytes.TrimSpace(data)) + } + return kernelInfo, nil +} + +// OSRelease returns the operating system identification data as defined by the +// os-release specification. +func OSRelease(fs vfs.FS) (map[string]string, error) { + for _, filename := range []string{ + "/usr/lib/os-release", + "/etc/os-release", + } { + data, err := fs.ReadFile(filename) + if os.IsNotExist(err) { + continue + } else if err != nil { + return nil, err + } + m, err := parseOSRelease(bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + return m, nil + } + return nil, os.ErrNotExist +} + +// maybeUnquote removes quotation marks around s. +func maybeUnquote(s string) string { + // Try to unquote. + if s, err := strconv.Unquote(s); err == nil { + return s + } + // Otherwise return s, unchanged. + return s +} + +// parseOSRelease parses operating system identification data from r as defined +// by the os-release specification. +func parseOSRelease(r io.Reader) (map[string]string, error) { + result := make(map[string]string) + s := bufio.NewScanner(r) + for s.Scan() { + // Trim all leading whitespace, but not necessarily trailing whitespace. + token := strings.TrimLeftFunc(s.Text(), unicode.IsSpace) + // If the line is empty or starts with #, skip. + if len(token) == 0 || token[0] == '#' { + continue + } + fields := strings.SplitN(token, "=", 2) + if len(fields) != 2 { + return nil, fmt.Errorf("%s: parse error", token) + } + key := fields[0] + value := maybeUnquote(fields[1]) + result[key] = value + } + return result, s.Err() +} diff --git a/chezmoi2/internal/chezmoi/data_linux_test.go b/chezmoi2/internal/chezmoi/data_linux_test.go new file mode 100644 index 000000000000..9210fe6eec03 --- /dev/null +++ b/chezmoi2/internal/chezmoi/data_linux_test.go @@ -0,0 +1,213 @@ +package chezmoi + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestKernelInfo(t *testing.T) { + for _, tc := range []struct { + name string + root interface{} + expectedKernelInfo map[string]string + }{ + { + name: "windows_services_for_linux", + root: map[string]interface{}{ + "/proc/sys/kernel": map[string]interface{}{ + "osrelease": "4.19.81-microsoft-standard\n", + "ostype": "Linux\n", + "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)\n", + }, + }, + expectedKernelInfo: map[string]string{ + "osrelease": "4.19.81-microsoft-standard", + "ostype": "Linux", + "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)", + }, + }, + { + name: "debian_version_only", + root: map[string]interface{}{ + "/proc/sys/kernel": map[string]interface{}{ + "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)\n", + }, + }, + expectedKernelInfo: map[string]string{ + "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)", + }, + }, + { + name: "proc_sys_kernel_missing", + root: map[string]interface{}{ + "/proc/sys": &vfst.Dir{Perm: 0o755}, + }, + expectedKernelInfo: nil, + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fs vfs.FS) { + actual, err := KernelInfo(fs) + assert.NoError(t, err) + assert.Equal(t, tc.expectedKernelInfo, actual) + }) + }) + } +} + +func TestOSRelease(t *testing.T) { + for _, tc := range []struct { + name string + root map[string]interface{} + expected map[string]string + }{ + { + name: "fedora", + root: map[string]interface{}{ + "/etc/os-release": chezmoitest.JoinLines( + `NAME=Fedora`, + `VERSION="17 (Beefy Miracle)"`, + `ID=fedora`, + `VERSION_ID=17`, + `PRETTY_NAME="Fedora 17 (Beefy Miracle)"`, + `ANSI_COLOR="0;34"`, + `CPE_NAME="cpe:/o:fedoraproject:fedora:17"`, + `HOME_URL="https://fedoraproject.org/"`, + `BUG_REPORT_URL="https://bugzilla.redhat.com/"`, + ), + }, + expected: map[string]string{ + "NAME": "Fedora", + "VERSION": "17 (Beefy Miracle)", + "ID": "fedora", + "VERSION_ID": "17", + "PRETTY_NAME": "Fedora 17 (Beefy Miracle)", + "ANSI_COLOR": "0;34", + "CPE_NAME": "cpe:/o:fedoraproject:fedora:17", + "HOME_URL": "https://fedoraproject.org/", + "BUG_REPORT_URL": "https://bugzilla.redhat.com/", + }, + }, + { + name: "ubuntu", + root: map[string]interface{}{ + "/usr/lib/os-release": chezmoitest.JoinLines( + `NAME="Ubuntu"`, + `VERSION="18.04.1 LTS (Bionic Beaver)"`, + `ID=ubuntu`, + `ID_LIKE=debian`, + `PRETTY_NAME="Ubuntu 18.04.1 LTS"`, + `VERSION_ID="18.04"`, + `HOME_URL="https://www.ubuntu.com/"`, + `SUPPORT_URL="https://help.ubuntu.com/"`, + `BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"`, + `PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"`, + `VERSION_CODENAME=bionic`, + `UBUNTU_CODENAME=bionic`, + ), + }, + expected: map[string]string{ + "NAME": "Ubuntu", + "VERSION": "18.04.1 LTS (Bionic Beaver)", + "ID": "ubuntu", + "ID_LIKE": "debian", + "PRETTY_NAME": "Ubuntu 18.04.1 LTS", + "VERSION_ID": "18.04", + "HOME_URL": "https://www.ubuntu.com/", + "SUPPORT_URL": "https://help.ubuntu.com/", + "BUG_REPORT_URL": "https://bugs.launchpad.net/ubuntu/", + "PRIVACY_POLICY_URL": "https://www.ubuntu.com/legal/terms-and-policies/privacy-policy", + "VERSION_CODENAME": "bionic", + "UBUNTU_CODENAME": "bionic", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fs vfs.FS) { + actual, err := OSRelease(fs) + assert.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + }) + } +} + +func TestParseOSRelease(t *testing.T) { + for _, tc := range []struct { + name string + s string + expected map[string]string + }{ + { + name: "fedora", + s: chezmoitest.JoinLines( + `NAME=Fedora`, + `VERSION="17 (Beefy Miracle)"`, + `ID=fedora`, + `VERSION_ID=17`, + `PRETTY_NAME="Fedora 17 (Beefy Miracle)"`, + `ANSI_COLOR="0;34"`, + `CPE_NAME="cpe:/o:fedoraproject:fedora:17"`, + `HOME_URL="https://fedoraproject.org/"`, + `BUG_REPORT_URL="https://bugzilla.redhat.com/"`, + ), + expected: map[string]string{ + "NAME": "Fedora", + "VERSION": "17 (Beefy Miracle)", + "ID": "fedora", + "VERSION_ID": "17", + "PRETTY_NAME": "Fedora 17 (Beefy Miracle)", + "ANSI_COLOR": "0;34", + "CPE_NAME": "cpe:/o:fedoraproject:fedora:17", + "HOME_URL": "https://fedoraproject.org/", + "BUG_REPORT_URL": "https://bugzilla.redhat.com/", + }, + }, + { + name: "ubuntu_with_comments", + s: chezmoitest.JoinLines( + `NAME="Ubuntu"`, + `VERSION="18.04.1 LTS (Bionic Beaver)"`, + `ID=ubuntu`, + `ID_LIKE=debian`, + `PRETTY_NAME="Ubuntu 18.04.1 LTS"`, + `VERSION_ID="18.04"`, + `HOME_URL="https://www.ubuntu.com/"`, + `SUPPORT_URL="https://help.ubuntu.com/"`, + `BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"`, + `PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"`, + `# comment`, + ``, + ` # comment`, + `VERSION_CODENAME=bionic`, + `UBUNTU_CODENAME=bionic`, + ), + expected: map[string]string{ + "NAME": "Ubuntu", + "VERSION": "18.04.1 LTS (Bionic Beaver)", + "ID": "ubuntu", + "ID_LIKE": "debian", + "PRETTY_NAME": "Ubuntu 18.04.1 LTS", + "VERSION_ID": "18.04", + "HOME_URL": "https://www.ubuntu.com/", + "SUPPORT_URL": "https://help.ubuntu.com/", + "BUG_REPORT_URL": "https://bugs.launchpad.net/ubuntu/", + "PRIVACY_POLICY_URL": "https://www.ubuntu.com/legal/terms-and-policies/privacy-policy", + "VERSION_CODENAME": "bionic", + "UBUNTU_CODENAME": "bionic", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actual, err := parseOSRelease(bytes.NewBufferString(tc.s)) + assert.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/chezmoi2/internal/chezmoi/data_notlinux.go b/chezmoi2/internal/chezmoi/data_notlinux.go new file mode 100644 index 000000000000..60c0c1d621cf --- /dev/null +++ b/chezmoi2/internal/chezmoi/data_notlinux.go @@ -0,0 +1,17 @@ +// +build !linux + +package chezmoi + +import ( + "github.com/twpayne/go-vfs" +) + +// KernelInfo returns nothing on non-Linux systems. +func KernelInfo(fs vfs.FS) (map[string]string, error) { + return nil, nil +} + +// OSRelease returns nothing on non-Linux systems. +func OSRelease(fs vfs.FS) (map[string]string, error) { + return nil, nil +} diff --git a/chezmoi2/internal/chezmoi/debugencryption.go b/chezmoi2/internal/chezmoi/debugencryption.go new file mode 100644 index 000000000000..ab9d4e7e8ea5 --- /dev/null +++ b/chezmoi2/internal/chezmoi/debugencryption.go @@ -0,0 +1,63 @@ +package chezmoi + +import ( + "github.com/rs/zerolog/log" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoilog" +) + +// A DebugEncryption logs all calls to an Encryption. +type DebugEncryption struct { + encryption Encryption +} + +// NewDebugEncryption returns a new DebugEncryption. +func NewDebugEncryption(encryption Encryption) *DebugEncryption { + return &DebugEncryption{ + encryption: encryption, + } +} + +// Decrypt implements Encryption.Decrypt. +func (e *DebugEncryption) Decrypt(ciphertext []byte) ([]byte, error) { + plaintext, err := e.encryption.Decrypt(ciphertext) + log.Debug(). + Bytes("ciphertext", chezmoilog.FirstFewBytes(ciphertext)). + Err(err). + Bytes("plaintext", chezmoilog.FirstFewBytes(plaintext)). + Msg("Decrypt") + return plaintext, err +} + +// DecryptToFile implements Encryption.DecryptToFile. +func (e *DebugEncryption) DecryptToFile(filename string, ciphertext []byte) error { + err := e.encryption.DecryptToFile(filename, ciphertext) + log.Debug(). + Str("filename", filename). + Bytes("ciphertext", chezmoilog.FirstFewBytes(ciphertext)). + Err(err). + Msg("DecryptToFile") + return err +} + +// Encrypt implements Encryption.Encrypt. +func (e *DebugEncryption) Encrypt(plaintext []byte) ([]byte, error) { + ciphertext, err := e.encryption.Encrypt(plaintext) + log.Debug(). + Bytes("plaintext", chezmoilog.FirstFewBytes(plaintext)). + Err(err). + Bytes("ciphertext", chezmoilog.FirstFewBytes(ciphertext)). + Msg("Encrypt") + return ciphertext, err +} + +// EncryptFile implements Encryption.EncryptFile. +func (e *DebugEncryption) EncryptFile(filename string) ([]byte, error) { + ciphertext, err := e.encryption.EncryptFile(filename) + log.Debug(). + Str("filename", filename). + Err(err). + Bytes("ciphertext", chezmoilog.FirstFewBytes(ciphertext)). + Msg("EncryptFile") + return ciphertext, err +} diff --git a/chezmoi2/internal/chezmoi/debugpersistentstate.go b/chezmoi2/internal/chezmoi/debugpersistentstate.go new file mode 100644 index 000000000000..db7e0823ca27 --- /dev/null +++ b/chezmoi2/internal/chezmoi/debugpersistentstate.go @@ -0,0 +1,88 @@ +package chezmoi + +import ( + "github.com/rs/zerolog/log" +) + +// A DebugPersistentState logs calls to a PersistentState. +type DebugPersistentState struct { + persistentState PersistentState +} + +// NewDebugPersistentState returns a new debugPersistentState. +func NewDebugPersistentState(persistentState PersistentState) *DebugPersistentState { + return &DebugPersistentState{ + persistentState: persistentState, + } +} + +// Close implements PersistentState.Close. +func (s *DebugPersistentState) Close() error { + err := s.persistentState.Close() + log.Logger.Debug(). + Err(err). + Msg("Close") + return err +} + +// CopyTo implements PersistentState.CopyTo. +func (s *DebugPersistentState) CopyTo(p PersistentState) error { + err := s.persistentState.CopyTo(p) + log.Logger.Debug(). + Err(err). + Msg("CopyTo") + return err +} + +// Delete implements PersistentState.Delete. +func (s *DebugPersistentState) Delete(bucket, key []byte) error { + err := s.persistentState.Delete(bucket, key) + log.Logger.Debug(). + Bytes("bucket", bucket). + Bytes("key", key). + Err(err). + Msg("Delete") + return err +} + +// ForEach implements PersistentState.ForEach. +func (s *DebugPersistentState) ForEach(bucket []byte, fn func(k, v []byte) error) error { + err := s.persistentState.ForEach(bucket, func(k, v []byte) error { + err := fn(k, v) + log.Logger.Debug(). + Bytes("bucket", bucket). + Bytes("key", k). + Bytes("value", v). + Err(err). + Msg("ForEach") + return err + }) + log.Logger.Debug(). + Bytes("bucket", bucket). + Err(err) + return err +} + +// Get implements PersistentState.Get. +func (s *DebugPersistentState) Get(bucket, key []byte) ([]byte, error) { + value, err := s.persistentState.Get(bucket, key) + log.Logger.Debug(). + Bytes("bucket", bucket). + Bytes("key", key). + Bytes("value", value). + Err(err). + Msg("Get") + return value, err +} + +// Set implements PersistentState.Set. +func (s *DebugPersistentState) Set(bucket, key, value []byte) error { + err := s.persistentState.Set(bucket, key, value) + log.Logger.Debug(). + Bytes("bucket", bucket). + Bytes("key", key). + Bytes("value", value). + Err(err). + Msg("Set") + return err +} diff --git a/chezmoi2/internal/chezmoi/debugsystem.go b/chezmoi2/internal/chezmoi/debugsystem.go new file mode 100644 index 000000000000..5e404b540953 --- /dev/null +++ b/chezmoi2/internal/chezmoi/debugsystem.go @@ -0,0 +1,198 @@ +package chezmoi + +import ( + "os" + "os/exec" + + "github.com/rs/zerolog/log" + vfs "github.com/twpayne/go-vfs" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoilog" +) + +// A DebugSystem logs all calls to a System. +type DebugSystem struct { + system System +} + +// NewDebugSystem returns a new DebugSystem. +func NewDebugSystem(system System) *DebugSystem { + return &DebugSystem{ + system: system, + } +} + +// Chmod implements System.Chmod. +func (s *DebugSystem) Chmod(name AbsPath, mode os.FileMode) error { + err := s.system.Chmod(name, mode) + log.Logger.Debug(). + Str("name", string(name)). + Int("mode", int(mode)). + Err(err). + Msg("Chmod") + return err +} + +// Glob implements System.Glob. +func (s *DebugSystem) Glob(name string) ([]string, error) { + matches, err := s.system.Glob(name) + log.Logger.Debug(). + Str("name", name). + Strs("matches", matches). + Err(err). + Msg("Glob") + return matches, err +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *DebugSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + output, err := s.system.IdempotentCmdOutput(cmd) + log.Logger.Debug(). + EmbedObject(chezmoilog.OSExecCmdLogObject{Cmd: cmd}). + Bytes("output", chezmoilog.FirstFewBytes(output)). + Err(err). + EmbedObject(chezmoilog.OSExecExitErrorLogObject{Err: err}). + Msg("IdempotentCmdOutput") + return output, err +} + +// Lstat implements System.Lstat. +func (s *DebugSystem) Lstat(name AbsPath) (os.FileInfo, error) { + info, err := s.system.Lstat(name) + log.Logger.Debug(). + Str("name", string(name)). + Err(err). + Msg("Lstat") + return info, err +} + +// Mkdir implements System.Mkdir. +func (s *DebugSystem) Mkdir(name AbsPath, perm os.FileMode) error { + err := s.system.Mkdir(name, perm) + log.Logger.Debug(). + Str("name", string(name)). + Int("perm", int(perm)). + Err(err). + Msg("Mkdir") + return err +} + +// RawPath implements System.RawPath. +func (s *DebugSystem) RawPath(path AbsPath) (AbsPath, error) { + return s.system.RawPath(path) +} + +// ReadDir implements System.ReadDir. +func (s *DebugSystem) ReadDir(name AbsPath) ([]os.FileInfo, error) { + infos, err := s.system.ReadDir(name) + log.Logger.Debug(). + Str("name", string(name)). + Err(err). + Msg("ReadDir") + return infos, err +} + +// ReadFile implements System.ReadFile. +func (s *DebugSystem) ReadFile(filename AbsPath) ([]byte, error) { + data, err := s.system.ReadFile(filename) + log.Logger.Debug(). + Str("filename", string(filename)). + Bytes("data", chezmoilog.FirstFewBytes(data)). + Err(err). + Msg("ReadFile") + return data, err +} + +// Readlink implements System.Readlink. +func (s *DebugSystem) Readlink(name AbsPath) (string, error) { + linkname, err := s.system.Readlink(name) + log.Logger.Debug(). + Str("name", string(name)). + Str("linkname", linkname). + Err(err). + Msg("Readlink") + return linkname, err +} + +// RemoveAll implements System.RemoveAll. +func (s *DebugSystem) RemoveAll(name AbsPath) error { + err := s.system.RemoveAll(name) + log.Logger.Debug(). + Str("name", string(name)). + Err(err). + Msg("RemoveAll") + return err +} + +// Rename implements System.Rename. +func (s *DebugSystem) Rename(oldpath, newpath AbsPath) error { + err := s.system.Rename(oldpath, newpath) + log.Logger.Debug(). + Str("oldpath", string(oldpath)). + Str("newpath", string(newpath)). + Err(err). + Msg("Rename") + return err +} + +// RunCmd implements System.RunCmd. +func (s *DebugSystem) RunCmd(cmd *exec.Cmd) error { + err := s.system.RunCmd(cmd) + log.Logger.Debug(). + EmbedObject(chezmoilog.OSExecCmdLogObject{Cmd: cmd}). + Err(err). + EmbedObject(chezmoilog.OSExecExitErrorLogObject{Err: err}). + Msg("RunCmd") + return err +} + +// RunScript implements System.RunScript. +func (s *DebugSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte) error { + err := s.system.RunScript(scriptname, dir, data) + log.Logger.Debug(). + Str("scriptname", string(scriptname)). + Str("dir", string(dir)). + Bytes("data", chezmoilog.FirstFewBytes(data)). + Err(err). + EmbedObject(chezmoilog.OSExecExitErrorLogObject{Err: err}). + Msg("RunScript") + return err +} + +// Stat implements System.Stat. +func (s *DebugSystem) Stat(name AbsPath) (os.FileInfo, error) { + info, err := s.system.Stat(name) + log.Logger.Debug(). + Str("name", string(name)). + Err(err). + Msg("Stat") + return info, err +} + +// UnderlyingFS implements System.UnderlyingFS. +func (s *DebugSystem) UnderlyingFS() vfs.FS { + return s.system.UnderlyingFS() +} + +// WriteFile implements System.WriteFile. +func (s *DebugSystem) WriteFile(name AbsPath, data []byte, perm os.FileMode) error { + err := s.system.WriteFile(name, data, perm) + log.Logger.Debug(). + Str("name", string(name)). + Bytes("data", chezmoilog.FirstFewBytes(data)). + Int("perm", int(perm)). + Err(err). + Msg("WriteFile") + return err +} + +// WriteSymlink implements System.WriteSymlink. +func (s *DebugSystem) WriteSymlink(oldname string, newname AbsPath) error { + err := s.system.WriteSymlink(oldname, newname) + log.Logger.Debug(). + Str("oldname", oldname). + Str("newname", string(newname)). + Err(err). + Msg("WriteSymlink") + return err +} diff --git a/chezmoi2/internal/chezmoi/debugsystem_test.go b/chezmoi2/internal/chezmoi/debugsystem_test.go new file mode 100644 index 000000000000..7dae1a6caf86 --- /dev/null +++ b/chezmoi2/internal/chezmoi/debugsystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &DebugSystem{} diff --git a/chezmoi2/internal/chezmoi/doublestaros.go b/chezmoi2/internal/chezmoi/doublestaros.go new file mode 100644 index 000000000000..a9554c836cd1 --- /dev/null +++ b/chezmoi2/internal/chezmoi/doublestaros.go @@ -0,0 +1,21 @@ +package chezmoi + +import ( + "os" + + "github.com/bmatcuk/doublestar/v3" + vfs "github.com/twpayne/go-vfs" +) + +// A doubleStarOS embeds a vfs.FS into a value that implements doublestar.OS. +type doubleStarOS struct { + vfs.FS +} + +func (os doubleStarOS) Lstat(name string) (os.FileInfo, error) { + return os.FS.Lstat(name) +} + +func (os doubleStarOS) Open(name string) (doublestar.File, error) { + return os.FS.Open(name) +} diff --git a/chezmoi2/internal/chezmoi/dryrunsystem.go b/chezmoi2/internal/chezmoi/dryrunsystem.go new file mode 100644 index 000000000000..ca52e9ceb55b --- /dev/null +++ b/chezmoi2/internal/chezmoi/dryrunsystem.go @@ -0,0 +1,121 @@ +package chezmoi + +import ( + "os" + "os/exec" + + vfs "github.com/twpayne/go-vfs" +) + +// DryRunSystem is an System that reads from, but does not write to, to +// a wrapped System. +type DryRunSystem struct { + system System + modified bool +} + +// NewDryRunSystem returns a new DryRunSystem that wraps fs. +func NewDryRunSystem(system System) *DryRunSystem { + return &DryRunSystem{ + system: system, + } +} + +// Chmod implements System.Chmod. +func (s *DryRunSystem) Chmod(name AbsPath, mode os.FileMode) error { + s.modified = true + return nil +} + +// Glob implements System.Glob. +func (s *DryRunSystem) Glob(pattern string) ([]string, error) { + return s.system.Glob(pattern) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *DryRunSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return s.system.IdempotentCmdOutput(cmd) +} + +// Lstat implements System.Lstat. +func (s *DryRunSystem) Lstat(name AbsPath) (os.FileInfo, error) { + return s.system.Lstat(name) +} + +// Mkdir implements System.Mkdir. +func (s *DryRunSystem) Mkdir(name AbsPath, perm os.FileMode) error { + s.modified = true + return nil +} + +// Modified returns true if a method that would have modified the wrapped system +// has been called. +func (s *DryRunSystem) Modified() bool { + return s.modified +} + +// RawPath implements System.RawPath. +func (s *DryRunSystem) RawPath(path AbsPath) (AbsPath, error) { + return s.system.RawPath(path) +} + +// ReadDir implements System.ReadDir. +func (s *DryRunSystem) ReadDir(dirname AbsPath) ([]os.FileInfo, error) { + return s.system.ReadDir(dirname) +} + +// ReadFile implements System.ReadFile. +func (s *DryRunSystem) ReadFile(filename AbsPath) ([]byte, error) { + return s.system.ReadFile(filename) +} + +// Readlink implements System.Readlink. +func (s *DryRunSystem) Readlink(name AbsPath) (string, error) { + return s.system.Readlink(name) +} + +// RemoveAll implements System.RemoveAll. +func (s *DryRunSystem) RemoveAll(AbsPath) error { + s.modified = true + return nil +} + +// Rename implements System.Rename. +func (s *DryRunSystem) Rename(oldpath, newpath AbsPath) error { + s.modified = true + return nil +} + +// RunCmd implements System.RunCmd. +func (s *DryRunSystem) RunCmd(cmd *exec.Cmd) error { + s.modified = true + return nil +} + +// RunScript implements System.RunScript. +func (s *DryRunSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte) error { + s.modified = true + return nil +} + +// Stat implements System.Stat. +func (s *DryRunSystem) Stat(name AbsPath) (os.FileInfo, error) { + return s.system.Stat(name) +} + +// UnderlyingFS implements System.UnderlyingFS. +func (s *DryRunSystem) UnderlyingFS() vfs.FS { + return s.system.UnderlyingFS() +} + +// WriteFile implements System.WriteFile. +func (s *DryRunSystem) WriteFile(AbsPath, []byte, os.FileMode) error { + s.modified = true + return nil +} + +// WriteSymlink implements System.WriteSymlink. +func (s *DryRunSystem) WriteSymlink(string, AbsPath) error { + s.modified = true + return nil +} diff --git a/chezmoi2/internal/chezmoi/dryrunsystem_test.go b/chezmoi2/internal/chezmoi/dryrunsystem_test.go new file mode 100644 index 000000000000..1d9eceff53b7 --- /dev/null +++ b/chezmoi2/internal/chezmoi/dryrunsystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &DryRunSystem{} diff --git a/chezmoi2/internal/chezmoi/dumpsystem.go b/chezmoi2/internal/chezmoi/dumpsystem.go new file mode 100644 index 000000000000..e17f14b62e78 --- /dev/null +++ b/chezmoi2/internal/chezmoi/dumpsystem.go @@ -0,0 +1,125 @@ +package chezmoi + +import ( + "os" + + vfs "github.com/twpayne/go-vfs" +) + +// A dataType is a data type. +type dataType string + +// dataTypes. +const ( + dataTypeDir dataType = "dir" + dataTypeFile dataType = "file" + dataTypeScript dataType = "script" + dataTypeSymlink dataType = "symlink" +) + +// A DumpSystem is a System that writes to a data file. +type DumpSystem struct { + emptySystemMixin + noUpdateSystemMixin + data map[AbsPath]interface{} +} + +// A dirData contains data about a directory. +type dirData struct { + Type dataType `json:"type" toml:"type" yaml:"type"` + Name AbsPath `json:"name" toml:"name" yaml:"name"` + Perm os.FileMode `json:"perm" toml:"perm" yaml:"perm"` +} + +// A fileData contains data about a file. +type fileData struct { + Type dataType `json:"type" toml:"type" yaml:"type"` + Name AbsPath `json:"name" toml:"name" yaml:"name"` + Contents string `json:"contents" toml:"contents" yaml:"contents"` + Perm os.FileMode `json:"perm" toml:"perm" yaml:"perm"` +} + +// A scriptData contains data about a script. +type scriptData struct { + Type dataType `json:"type" toml:"type" yaml:"type"` + Name AbsPath `json:"name" toml:"name" yaml:"name"` + Contents string `json:"contents" toml:"contents" yaml:"contents"` +} + +// A symlinkData contains data about a symlink. +type symlinkData struct { + Type dataType `json:"type" toml:"type" yaml:"type"` + Name AbsPath `json:"name" toml:"name" yaml:"name"` + Linkname string `json:"linkname" toml:"linkname" yaml:"linkname"` +} + +// NewDumpSystem returns a new DumpSystem that accumulates data. +func NewDumpSystem() *DumpSystem { + return &DumpSystem{ + data: make(map[AbsPath]interface{}), + } +} + +// Data returns s's data. +func (s *DumpSystem) Data() interface{} { + return s.data +} + +// Mkdir implements System.Mkdir. +func (s *DumpSystem) Mkdir(dirname AbsPath, perm os.FileMode) error { + if _, exists := s.data[dirname]; exists { + return os.ErrExist + } + s.data[dirname] = &dirData{ + Type: dataTypeDir, + Name: dirname, + Perm: perm, + } + return nil +} + +// RunScript implements System.RunScript. +func (s *DumpSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte) error { + scriptnameAbsPath := AbsPath(scriptname) + if _, exists := s.data[scriptnameAbsPath]; exists { + return os.ErrExist + } + s.data[scriptnameAbsPath] = &scriptData{ + Type: dataTypeScript, + Name: scriptnameAbsPath, + Contents: string(data), + } + return nil +} + +// UnderlyingFS implements System.UnderlyingFS. +func (s *DumpSystem) UnderlyingFS() vfs.FS { + return nil +} + +// WriteFile implements System.WriteFile. +func (s *DumpSystem) WriteFile(filename AbsPath, data []byte, perm os.FileMode) error { + if _, exists := s.data[filename]; exists { + return os.ErrExist + } + s.data[filename] = &fileData{ + Type: dataTypeFile, + Name: filename, + Contents: string(data), + Perm: perm, + } + return nil +} + +// WriteSymlink implements System.WriteSymlink. +func (s *DumpSystem) WriteSymlink(oldname string, newname AbsPath) error { + if _, exists := s.data[newname]; exists { + return os.ErrExist + } + s.data[newname] = &symlinkData{ + Type: dataTypeSymlink, + Name: newname, + Linkname: oldname, + } + return nil +} diff --git a/chezmoi2/internal/chezmoi/dumpsystem_test.go b/chezmoi2/internal/chezmoi/dumpsystem_test.go new file mode 100644 index 000000000000..a13f983dc4e9 --- /dev/null +++ b/chezmoi2/internal/chezmoi/dumpsystem_test.go @@ -0,0 +1,68 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +var _ System = &DumpSystem{} + +func TestDumpSystem(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + ".chezmoiremove": "*.txt\n", + ".chezmoiversion": "1.2.3\n", + ".chezmoitemplates": map[string]interface{}{ + "template": "# contents of .chezmoitemplates/template\n", + }, + "README.md": "", + "dot_dir": map[string]interface{}{ + "file": "# contents of .dir/file\n", + }, + "run_script": "# contents of script\n", + "symlink_symlink": ".dir/subdir/file\n", + }, + }, func(fs vfs.FS) { + s := NewSourceState( + WithSourceDir("/home/user/.local/share/chezmoi"), + WithSystem(NewRealSystem(fs)), + ) + require.NoError(t, s.Read()) + require.NoError(t, s.evaluateAll()) + + dumpSystem := NewDumpSystem() + persistentState := NewMockPersistentState() + require.NoError(t, s.applyAll(dumpSystem, persistentState, "", ApplyOptions{})) + expectedData := map[AbsPath]interface{}{ + ".dir": &dirData{ + Type: dataTypeDir, + Name: ".dir", + Perm: 0o777, + }, + ".dir/file": &fileData{ + Type: dataTypeFile, + Name: ".dir/file", + Contents: "# contents of .dir/file\n", + Perm: 0o666, + }, + "script": &scriptData{ + Type: dataTypeScript, + Name: "script", + Contents: "# contents of script\n", + }, + "symlink": &symlinkData{ + Type: dataTypeSymlink, + Name: "symlink", + Linkname: ".dir/subdir/file", + }, + } + actualData := dumpSystem.Data() + assert.Equal(t, expectedData, actualData) + }) +} diff --git a/chezmoi2/internal/chezmoi/encryption.go b/chezmoi2/internal/chezmoi/encryption.go new file mode 100644 index 000000000000..63c125035cf0 --- /dev/null +++ b/chezmoi2/internal/chezmoi/encryption.go @@ -0,0 +1,9 @@ +package chezmoi + +// An Encryption encrypts and decrypts files and data. +type Encryption interface { + Decrypt(ciphertext []byte) ([]byte, error) + DecryptToFile(filename string, ciphertext []byte) error + Encrypt(plaintext []byte) ([]byte, error) + EncryptFile(filename string) ([]byte, error) +} diff --git a/chezmoi2/internal/chezmoi/encryption_test.go b/chezmoi2/internal/chezmoi/encryption_test.go new file mode 100644 index 000000000000..97acf0050ebe --- /dev/null +++ b/chezmoi2/internal/chezmoi/encryption_test.go @@ -0,0 +1,123 @@ +package chezmoi + +import ( + "io/ioutil" + "math/rand" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type xorEncryption struct { + key byte +} + +var _ Encryption = &xorEncryption{} + +func (t *xorEncryption) Decrypt(ciphertext []byte) ([]byte, error) { + return t.xorWithKey(ciphertext), nil +} + +func (t *xorEncryption) DecryptToFile(filename string, ciphertext []byte) error { + return ioutil.WriteFile(filename, t.xorWithKey(ciphertext), 0o666) +} + +func (t *xorEncryption) Encrypt(plaintext []byte) ([]byte, error) { + return t.xorWithKey(plaintext), nil +} + +func (t *xorEncryption) EncryptFile(filename string) ([]byte, error) { + plaintext, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + return t.xorWithKey(plaintext), nil +} + +func (t *xorEncryption) xorWithKey(input []byte) []byte { + output := make([]byte, 0, len(input)) + for _, b := range input { + output = append(output, b^t.key) + } + return output +} + +func testEncryptionDecryptToFile(t *testing.T, encryption Encryption) { + t.Helper() + t.Run("DecryptToFile", func(t *testing.T) { + expectedPlaintext := []byte("plaintext\n") + + actualCiphertext, err := encryption.Encrypt(expectedPlaintext) + require.NoError(t, err) + require.NotEmpty(t, actualCiphertext) + assert.NotEqual(t, expectedPlaintext, actualCiphertext) + + tempDir, err := ioutil.TempDir("", "chezmoi-test-encryption") + require.NoError(t, err) + defer func() { + assert.NoError(t, os.RemoveAll(tempDir)) + }() + filename := filepath.Join(tempDir, "filename") + + require.NoError(t, encryption.DecryptToFile(filename, actualCiphertext)) + + actualPlaintext, err := ioutil.ReadFile(filename) + require.NoError(t, err) + require.NotEmpty(t, actualPlaintext) + assert.Equal(t, expectedPlaintext, actualPlaintext) + }) +} + +func testEncryptionEncryptDecrypt(t *testing.T, encryption Encryption) { + t.Helper() + t.Run("EncryptDecrypt", func(t *testing.T) { + expectedPlaintext := []byte("plaintext\n") + + actualCiphertext, err := encryption.Encrypt(expectedPlaintext) + require.NoError(t, err) + require.NotEmpty(t, actualCiphertext) + assert.NotEqual(t, expectedPlaintext, actualCiphertext) + + actualPlaintext, err := encryption.Decrypt(actualCiphertext) + require.NoError(t, err) + require.NotEmpty(t, actualPlaintext) + assert.Equal(t, expectedPlaintext, actualPlaintext) + }) +} + +func testEncryptionEncryptFile(t *testing.T, encryption Encryption) { + t.Helper() + t.Run("EncryptFile", func(t *testing.T) { + expectedPlaintext := []byte("plaintext\n") + + tempDir, err := ioutil.TempDir("", "chezmoi-test-encryption") + require.NoError(t, err) + defer func() { + assert.NoError(t, os.RemoveAll(tempDir)) + }() + filename := filepath.Join(tempDir, "filename") + require.NoError(t, ioutil.WriteFile(filename, expectedPlaintext, 0o666)) + + actualCiphertext, err := encryption.EncryptFile(filename) + require.NoError(t, err) + require.NotEmpty(t, actualCiphertext) + assert.NotEqual(t, expectedPlaintext, actualCiphertext) + + actualPlaintext, err := encryption.Decrypt(actualCiphertext) + require.NoError(t, err) + require.NotEmpty(t, actualPlaintext) + assert.Equal(t, expectedPlaintext, actualPlaintext) + }) +} + +func TestXOREncryption(t *testing.T) { + xorEncryption := &xorEncryption{ + key: byte(rand.Int() + 1), + } + testEncryptionDecryptToFile(t, xorEncryption) + testEncryptionEncryptDecrypt(t, xorEncryption) + testEncryptionEncryptFile(t, xorEncryption) +} diff --git a/chezmoi2/internal/chezmoi/entrystate.go b/chezmoi2/internal/chezmoi/entrystate.go new file mode 100644 index 000000000000..6d2a262a0531 --- /dev/null +++ b/chezmoi2/internal/chezmoi/entrystate.go @@ -0,0 +1,50 @@ +package chezmoi + +import ( + "bytes" + "os" +) + +// An EntryStateType is an entry state type. +type EntryStateType string + +// Entry state types. +const ( + EntryStateTypeAbsent EntryStateType = "absent" + EntryStateTypePresent EntryStateType = "present" + EntryStateTypeDir EntryStateType = "dir" + EntryStateTypeFile EntryStateType = "file" + EntryStateTypeSymlink EntryStateType = "symlink" + EntryStateTypeScript EntryStateType = "script" +) + +// An EntryState represents the state of an entry. A nil EntryState is +// equivalent to EntryStateTypeAbsent. +type EntryState struct { + Type EntryStateType `json:"type" toml:"type" yaml:"type"` + Mode os.FileMode `json:"mode,omitempty" toml:"mode,omitempty" yaml:"mode,omitempty"` + ContentsSHA256 hexBytes `json:"contentsSHA256,omitempty" toml:"contentsSHA256,omitempty" yaml:"contentsSHA256,omitempty"` +} + +// Equal returns true if s is equal to other. +func (s *EntryState) Equal(other *EntryState, umask os.FileMode) bool { + return s.Type == other.Type && + s.Mode&^umask == other.Mode&^umask && + bytes.Equal(s.ContentsSHA256, other.ContentsSHA256) +} + +// Equivalent returns true if s is equivalent to other. +func (s *EntryState) Equivalent(other *EntryState, umask os.FileMode) bool { + switch { + case s == nil: + return other == nil || other.Type == EntryStateTypeAbsent + case other == nil: + return s.Type == EntryStateTypeAbsent + case s.Type == EntryStateTypeFile: + return other.Type == EntryStateTypePresent || s.Equal(other, umask) + case s.Type == EntryStateTypePresent: + return other.Type == EntryStateTypeFile || other.Type == EntryStateTypePresent + default: + return s.Equal(other, umask) + } +} diff --git a/chezmoi2/internal/chezmoi/entrystate_test.go b/chezmoi2/internal/chezmoi/entrystate_test.go new file mode 100644 index 000000000000..30249e0c3213 --- /dev/null +++ b/chezmoi2/internal/chezmoi/entrystate_test.go @@ -0,0 +1,114 @@ +package chezmoi + +import ( + "fmt" + "os" + "sort" + "testing" + + "github.com/muesli/combinator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEntryStateEquivalent(t *testing.T) { + entryStates := map[string]*EntryState{ + "absent": { + Type: EntryStateTypeAbsent, + }, + "dir1": { + Type: EntryStateTypeDir, + Mode: os.ModeDir | 0o777, + }, + "dir1_copy": { + Type: EntryStateTypeDir, + Mode: os.ModeDir | 0o777, + }, + "dir_private": { + Type: EntryStateTypeDir, + Mode: os.ModeDir | 0o700, + }, + "file1": { + Type: EntryStateTypeFile, + Mode: 0o666, + ContentsSHA256: []byte{1}, + }, + "file1_copy": { + Type: EntryStateTypeFile, + Mode: 0o666, + ContentsSHA256: []byte{1}, + }, + "file2": { + Type: EntryStateTypeFile, + Mode: 0o666, + ContentsSHA256: []byte{2}, + }, + "nil1": nil, + "nil2": nil, + "present": { + Type: EntryStateTypePresent, + Mode: 0o666, + ContentsSHA256: []byte{3}, + }, + "script": { + Type: EntryStateTypeScript, + ContentsSHA256: []byte{4}, + }, + "symlink": { + Type: EntryStateTypeSymlink, + ContentsSHA256: []byte{5}, + }, + "symlink_copy": { + Type: EntryStateTypeSymlink, + ContentsSHA256: []byte{5}, + }, + } + + expectedEquivalents := map[string]bool{ + "absent_nil1": true, + "absent_nil2": true, + "dir1_copy_dir1": true, + "dir1_dir1_copy": true, + "file1_copy_file1": true, + "file1_copy_present": true, + "file1_file1_copy": true, + "file1_present": true, + "file2_present": true, + "nil1_absent": true, + "nil2_absent": true, + "present_file1_copy": true, + "present_file1": true, + "present_file2": true, + "symlink_copy_symlink": true, + "symlink_symlink_copy": true, + } + + entryStateKeys := make([]string, 0, len(entryStates)) + for entryStateKey := range entryStates { + entryStateKeys = append(entryStateKeys, entryStateKey) + } + sort.Strings(entryStateKeys) + + testData := struct { + EntryState1Key []string + EntryState2Key []string + }{ + EntryState1Key: entryStateKeys, + EntryState2Key: entryStateKeys, + } + var testCases []struct { + EntryState1Key string + EntryState2Key string + } + require.NoError(t, combinator.Generate(&testCases, testData)) + + for _, tc := range testCases { + name := fmt.Sprintf("%s_%s", tc.EntryState1Key, tc.EntryState2Key) + t.Run(name, func(t *testing.T) { + entryState1 := entryStates[tc.EntryState1Key] + entryState2 := entryStates[tc.EntryState2Key] + expectedEquivalent := entryState1 == entryState2 || expectedEquivalents[name] + assert.Equal(t, expectedEquivalent, entryState1.Equivalent(entryState2, 0o022)) + }) + } +} diff --git a/chezmoi2/internal/chezmoi/format.go b/chezmoi2/internal/chezmoi/format.go new file mode 100644 index 000000000000..8f349167cc6f --- /dev/null +++ b/chezmoi2/internal/chezmoi/format.go @@ -0,0 +1,83 @@ +package chezmoi + +import ( + "encoding/json" + "strings" + + "github.com/pelletier/go-toml" + "gopkg.in/yaml.v3" +) + +// A Format is a serialization format. +type Format interface { + Marshal(value interface{}) ([]byte, error) + Name() string + Unmarshal(data []byte, value interface{}) error +} + +// A jsonFormat implements the JSON serialization format. +type jsonFormat struct{} + +// A tomlFormat implements the TOML serialization format. +type tomlFormat struct{} + +// A yamlFormat implements the YAML serialization format. +type yamlFormat struct{} + +// Formats is a map of all Formats by name. +var Formats = map[string]Format{ + "json": jsonFormat{}, + "toml": tomlFormat{}, + "yaml": yamlFormat{}, +} + +// Marshal implements Format.Marshal. +func (jsonFormat) Marshal(value interface{}) ([]byte, error) { + sb := strings.Builder{} + e := json.NewEncoder(&sb) + e.SetIndent("", " ") + if err := e.Encode(value); err != nil { + return nil, err + } + return []byte(sb.String()), nil +} + +// Name implements Format.Name. +func (jsonFormat) Name() string { + return "json" +} + +// Unmarshal implements Format.Unmarshal. +func (jsonFormat) Unmarshal(data []byte, value interface{}) error { + return json.Unmarshal(data, value) +} + +// Marshal implements Format.Marshal. +func (tomlFormat) Marshal(value interface{}) ([]byte, error) { + return toml.Marshal(value) +} + +// Name implements Format.Name. +func (yamlFormat) Name() string { + return "yaml" +} + +// Unmarshal implements Format.Unmarshal. +func (tomlFormat) Unmarshal(data []byte, value interface{}) error { + return toml.Unmarshal(data, value) +} + +// Marshal implements Format.Marshal. +func (yamlFormat) Marshal(value interface{}) ([]byte, error) { + return yaml.Marshal(value) +} + +// Name implements Format.Name. +func (tomlFormat) Name() string { + return "toml" +} + +// Unmarshal implements Format.Unmarshal. +func (yamlFormat) Unmarshal(data []byte, value interface{}) error { + return yaml.Unmarshal(data, value) +} diff --git a/chezmoi2/internal/chezmoi/format_test.go b/chezmoi2/internal/chezmoi/format_test.go new file mode 100644 index 000000000000..3cd8331b2ac4 --- /dev/null +++ b/chezmoi2/internal/chezmoi/format_test.go @@ -0,0 +1,13 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormats(t *testing.T) { + assert.Contains(t, Formats, "json") + assert.Contains(t, Formats, "toml") + assert.Contains(t, Formats, "yaml") +} diff --git a/chezmoi2/internal/chezmoi/gitdiffsystem.go b/chezmoi2/internal/chezmoi/gitdiffsystem.go new file mode 100644 index 000000000000..7e2271ada3e2 --- /dev/null +++ b/chezmoi2/internal/chezmoi/gitdiffsystem.go @@ -0,0 +1,366 @@ +package chezmoi + +import ( + "io" + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/format/diff" + "github.com/sergi/go-diff/diffmatchpatch" + vfs "github.com/twpayne/go-vfs" +) + +// A GitDiffSystem wraps a System and logs all of the actions executed as a git +// diff. +type GitDiffSystem struct { + system System + dir AbsPath + unifiedEncoder *diff.UnifiedEncoder +} + +// NewGitDiffSystem returns a new GitDiffSystem. +func NewGitDiffSystem(system System, w io.Writer, dir AbsPath, color bool) *GitDiffSystem { + unifiedEncoder := diff.NewUnifiedEncoder(w, diff.DefaultContextLines) + if color { + unifiedEncoder.SetColor(diff.NewColorConfig()) + } + return &GitDiffSystem{ + system: system, + dir: dir, + unifiedEncoder: unifiedEncoder, + } +} + +// Chmod implements System.Chmod. +func (s *GitDiffSystem) Chmod(name AbsPath, mode os.FileMode) error { + fromFileMode, info, err := s.fileMode(name) + if err != nil { + return err + } + // Assume that we're only changing permissions. + toFileMode, err := filemode.NewFromOSFileMode(info.Mode()&^os.ModePerm | mode) + if err != nil { + return err + } + relPath := s.trimPrefix(name) + if err := s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + from: &gitDiffFile{ + fileMode: fromFileMode, + relPath: relPath, + hash: plumbing.ZeroHash, + }, + to: &gitDiffFile{ + fileMode: toFileMode, + relPath: relPath, + hash: plumbing.ZeroHash, + }, + }, + }, + }); err != nil { + return err + } + return s.system.Chmod(name, mode) +} + +// Glob implements System.Glob. +func (s *GitDiffSystem) Glob(pattern string) ([]string, error) { + return s.system.Glob(pattern) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *GitDiffSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return s.system.IdempotentCmdOutput(cmd) +} + +// Lstat implements System.Lstat. +func (s *GitDiffSystem) Lstat(name AbsPath) (os.FileInfo, error) { + return s.system.Lstat(name) +} + +// Mkdir implements System.Mkdir. +func (s *GitDiffSystem) Mkdir(name AbsPath, perm os.FileMode) error { + toFileMode, err := filemode.NewFromOSFileMode(os.ModeDir | perm) + if err != nil { + return err + } + if err := s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + to: &gitDiffFile{ + fileMode: toFileMode, + relPath: s.trimPrefix(name), + hash: plumbing.ZeroHash, + }, + }, + }, + }); err != nil { + return err + } + return s.system.Mkdir(name, perm) +} + +// RawPath implements System.RawPath. +func (s *GitDiffSystem) RawPath(path AbsPath) (AbsPath, error) { + return s.system.RawPath(path) +} + +// ReadDir implements System.ReadDir. +func (s *GitDiffSystem) ReadDir(dirname AbsPath) ([]os.FileInfo, error) { + return s.system.ReadDir(dirname) +} + +// ReadFile implements System.ReadFile. +func (s *GitDiffSystem) ReadFile(filename AbsPath) ([]byte, error) { + return s.system.ReadFile(filename) +} + +// Readlink implements System.Readlink. +func (s *GitDiffSystem) Readlink(name AbsPath) (string, error) { + return s.system.Readlink(name) +} + +// RemoveAll implements System.RemoveAll. +func (s *GitDiffSystem) RemoveAll(name AbsPath) error { + fromFileMode, _, err := s.fileMode(name) + if err != nil && !os.IsNotExist(err) { + return err + } + if err := s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + from: &gitDiffFile{ + fileMode: fromFileMode, + relPath: s.trimPrefix(name), + hash: plumbing.ZeroHash, + }, + }, + }, + }); err != nil { + return err + } + return s.system.RemoveAll(name) +} + +// Rename implements System.Rename. +func (s *GitDiffSystem) Rename(oldpath, newpath AbsPath) error { + fileMode, _, err := s.fileMode(oldpath) + if err != nil { + return err + } + if err := s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + from: &gitDiffFile{ + fileMode: fileMode, + relPath: s.trimPrefix(oldpath), + hash: plumbing.ZeroHash, + }, + to: &gitDiffFile{ + fileMode: fileMode, + relPath: s.trimPrefix(newpath), + hash: plumbing.ZeroHash, + }, + }, + }, + }); err != nil { + return err + } + return s.system.Rename(oldpath, newpath) +} + +// RunCmd implements System.RunCmd. +func (s *GitDiffSystem) RunCmd(cmd *exec.Cmd) error { + return s.system.RunCmd(cmd) +} + +// RunScript implements System.RunScript. +func (s *GitDiffSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte) error { + isBinary := isBinary(data) + var chunks []diff.Chunk + if !isBinary { + chunk := &gitDiffChunk{ + content: string(data), + operation: diff.Add, + } + chunks = append(chunks, chunk) + } + if err := s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + isBinary: isBinary, + to: &gitDiffFile{ + fileMode: filemode.Executable, + relPath: s.trimPrefix(AbsPath(scriptname)), + hash: plumbing.ComputeHash(plumbing.BlobObject, data), + }, + chunks: chunks, + }, + }, + }); err != nil { + return err + } + return s.system.RunScript(scriptname, dir, data) +} + +// Stat implements System.Stat. +func (s *GitDiffSystem) Stat(name AbsPath) (os.FileInfo, error) { + return s.system.Stat(name) +} + +// UnderlyingFS implements System.UnderlyingFS. +func (s *GitDiffSystem) UnderlyingFS() vfs.FS { + return s.system.UnderlyingFS() +} + +// WriteFile implements System.WriteFile. +func (s *GitDiffSystem) WriteFile(filename AbsPath, data []byte, perm os.FileMode) error { + fromFileMode, _, err := s.fileMode(filename) + var fromData []byte + switch { + case err == nil: + fromData, err = s.system.ReadFile(filename) + if err != nil { + return err + } + case os.IsNotExist(err): + default: + return err + } + toFileMode, err := filemode.NewFromOSFileMode(perm) + if err != nil { + return err + } + path := s.trimPrefix(filename) + isBinary := isBinary(fromData) || isBinary(data) + var chunks []diff.Chunk + if !isBinary { + chunks = diffChunks(string(fromData), string(data)) + } + if err := s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + isBinary: isBinary, + from: &gitDiffFile{ + fileMode: fromFileMode, + relPath: path, + hash: plumbing.ComputeHash(plumbing.BlobObject, fromData), + }, + to: &gitDiffFile{ + fileMode: toFileMode, + relPath: path, + hash: plumbing.ComputeHash(plumbing.BlobObject, data), + }, + chunks: chunks, + }, + }, + }); err != nil { + return err + } + return s.system.WriteFile(filename, data, perm) +} + +// WriteSymlink implements System.WriteSymlink. +func (s *GitDiffSystem) WriteSymlink(oldname string, newname AbsPath) error { + if err := s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + to: &gitDiffFile{ + fileMode: filemode.Symlink, + relPath: s.trimPrefix(newname), + hash: plumbing.ComputeHash(plumbing.BlobObject, []byte(oldname)), + }, + chunks: []diff.Chunk{ + &gitDiffChunk{ + content: oldname, + operation: diff.Add, + }, + }, + }, + }, + }); err != nil { + return err + } + return s.system.WriteSymlink(oldname, newname) +} + +func (s *GitDiffSystem) fileMode(name AbsPath) (filemode.FileMode, os.FileInfo, error) { + info, err := s.system.Stat(name) + if err != nil { + return filemode.Empty, nil, err + } + fileMode, err := filemode.NewFromOSFileMode(info.Mode()) + return fileMode, info, err +} + +func (s *GitDiffSystem) trimPrefix(absPath AbsPath) RelPath { + return absPath.MustTrimDirPrefix(s.dir) +} + +var gitDiffOperation = map[diffmatchpatch.Operation]diff.Operation{ + diffmatchpatch.DiffDelete: diff.Delete, + diffmatchpatch.DiffEqual: diff.Equal, + diffmatchpatch.DiffInsert: diff.Add, +} + +type gitDiffChunk struct { + content string + operation diff.Operation +} + +func (c *gitDiffChunk) Content() string { return c.content } +func (c *gitDiffChunk) Type() diff.Operation { return c.operation } + +type gitDiffFile struct { + hash plumbing.Hash + fileMode filemode.FileMode + relPath RelPath +} + +func (f *gitDiffFile) Hash() plumbing.Hash { return f.hash } +func (f *gitDiffFile) Mode() filemode.FileMode { return f.fileMode } +func (f *gitDiffFile) Path() string { return string(f.relPath) } + +type gitDiffFilePatch struct { + isBinary bool + from, to diff.File + chunks []diff.Chunk +} + +func (fp *gitDiffFilePatch) IsBinary() bool { return fp.isBinary } +func (fp *gitDiffFilePatch) Files() (diff.File, diff.File) { return fp.from, fp.to } +func (fp *gitDiffFilePatch) Chunks() []diff.Chunk { return fp.chunks } + +type gitDiffPatch struct { + filePatches []diff.FilePatch + message string +} + +func (p *gitDiffPatch) FilePatches() []diff.FilePatch { return p.filePatches } +func (p *gitDiffPatch) Message() string { return p.message } + +func diffChunks(from, to string) []diff.Chunk { + dmp := diffmatchpatch.New() + dmp.DiffTimeout = time.Second + fromRunes, toRunes, runesToLines := dmp.DiffLinesToRunes(from, to) + diffs := dmp.DiffCharsToLines(dmp.DiffMainRunes(fromRunes, toRunes, false), runesToLines) + chunks := make([]diff.Chunk, 0, len(diffs)) + for _, d := range diffs { + chunk := &gitDiffChunk{ + content: d.Text, + operation: gitDiffOperation[d.Type], + } + chunks = append(chunks, chunk) + } + return chunks +} + +func isBinary(data []byte) bool { + return len(data) != 0 && !strings.HasPrefix(http.DetectContentType(data), "text/") +} diff --git a/chezmoi2/internal/chezmoi/gitdiffsystem_test.go b/chezmoi2/internal/chezmoi/gitdiffsystem_test.go new file mode 100644 index 000000000000..77092fcf5839 --- /dev/null +++ b/chezmoi2/internal/chezmoi/gitdiffsystem_test.go @@ -0,0 +1,13 @@ +package chezmoi + +import ( + "github.com/go-git/go-git/v5/plumbing/format/diff" +) + +var ( + _ System = &GitDiffSystem{} + _ diff.Chunk = &gitDiffChunk{} + _ diff.File = &gitDiffFile{} + _ diff.FilePatch = &gitDiffFilePatch{} + _ diff.Patch = &gitDiffPatch{} +) diff --git a/chezmoi2/internal/chezmoi/gpgencryption.go b/chezmoi2/internal/chezmoi/gpgencryption.go new file mode 100644 index 000000000000..1f365a4b6e11 --- /dev/null +++ b/chezmoi2/internal/chezmoi/gpgencryption.go @@ -0,0 +1,78 @@ +package chezmoi + +import ( + "bytes" + "os" + "os/exec" + + "github.com/rs/zerolog/log" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoilog" +) + +// A GPGEncryption uses gpg for encryption and decryption. See https://gnupg.org/. +type GPGEncryption struct { + Command string + Args []string + Recipient string + Symmetric bool +} + +// Decrypt implements Encyrption.Decrypt. +func (t *GPGEncryption) Decrypt(ciphertext []byte) ([]byte, error) { + args := append([]string{"--decrypt"}, t.Args...) + //nolint:gosec + cmd := exec.Command(t.Command, args...) + cmd.Stdin = bytes.NewReader(ciphertext) + return chezmoilog.LogCmdOutput(log.Logger, cmd) +} + +// DecryptToFile implements Encryption.DecryptToFile. +func (t *GPGEncryption) DecryptToFile(filename string, ciphertext []byte) error { + args := append([]string{ + "--decrypt", + "--output", filename, + "--yes", + }, t.Args...) + //nolint:gosec + cmd := exec.Command(t.Command, args...) + cmd.Stdin = bytes.NewReader(ciphertext) + return chezmoilog.LogCmdRun(log.Logger, cmd) +} + +// Encrypt implements Encryption.Encrypt. +func (t *GPGEncryption) Encrypt(plaintext []byte) ([]byte, error) { + args := append(t.encryptArgs(), t.Args...) + //nolint:gosec + cmd := exec.Command(t.Command, args...) + cmd.Stdin = bytes.NewReader(plaintext) + return chezmoilog.LogCmdOutput(log.Logger, cmd) +} + +// EncryptFile implements Encryption.EncryptFile. +func (t *GPGEncryption) EncryptFile(filename string) (ciphertext []byte, err error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + args := append(t.encryptArgs(), t.Args...) + //nolint:gosec + cmd := exec.Command(t.Command, args...) + cmd.Stdin = f + return chezmoilog.LogCmdOutput(log.Logger, cmd) +} + +func (t *GPGEncryption) encryptArgs() []string { + args := []string{ + "--armor", + "--encrypt", + } + if t.Recipient != "" { + args = append(args, "--recipient", t.Recipient) + } + if t.Symmetric { + args = append(args, "--symmetric") + } + return args +} diff --git a/chezmoi2/internal/chezmoi/gpgencryption_test.go b/chezmoi2/internal/chezmoi/gpgencryption_test.go new file mode 100644 index 000000000000..7e56e48cb0b8 --- /dev/null +++ b/chezmoi2/internal/chezmoi/gpgencryption_test.go @@ -0,0 +1,66 @@ +package chezmoi + +import ( + "errors" + "io/ioutil" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestGPGEncryption(t *testing.T) { + if chezmoitest.GitHubActionsOnWindows() { + t.Skip("gpg is broken on Windows in GitHub Actions") + } + + command, err := chezmoitest.GPGCommand() + if errors.Is(err, exec.ErrNotFound) { + t.Skip("gpg not found in $PATH") + } + require.NoError(t, err) + + tempDir, err := ioutil.TempDir("", "chezmoi-test-gpg") + require.NoError(t, err) + defer func() { + require.NoError(t, os.RemoveAll(tempDir)) + }() + + key, passphrase, err := chezmoitest.GPGGenerateKey(command, tempDir) + require.NoError(t, err) + + for _, tc := range []struct { + name string + symmetric bool + }{ + { + name: "asymmetric", + symmetric: false, + }, + { + name: "symmetric", + symmetric: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + gpgEncryption := &GPGEncryption{ + Command: command, + Args: []string{ + "--homedir", tempDir, + "--no-tty", + "--passphrase", passphrase, + "--pinentry-mode", "loopback", + }, + Recipient: key, + Symmetric: tc.symmetric, + } + + testEncryptionDecryptToFile(t, gpgEncryption) + testEncryptionEncryptDecrypt(t, gpgEncryption) + testEncryptionEncryptFile(t, gpgEncryption) + }) + } +} diff --git a/chezmoi2/internal/chezmoi/hexbytes.go b/chezmoi2/internal/chezmoi/hexbytes.go new file mode 100644 index 000000000000..22e285f004ed --- /dev/null +++ b/chezmoi2/internal/chezmoi/hexbytes.go @@ -0,0 +1,33 @@ +package chezmoi + +import ( + "encoding/hex" +) + +// A hexBytes is a []byte which is marhsaled as a hex string. +type hexBytes []byte + +// MarshalText implements encoding.TextMarshaler.MarshalText. +func (h hexBytes) MarshalText() ([]byte, error) { + if len(h) == 0 { + return nil, nil + } + result := make([]byte, hex.EncodedLen(len(h))) + hex.Encode(result, h) + return result, nil +} + +// UnmarshalText implements encoding.TextMarshaler.UnmarshalText. +func (h *hexBytes) UnmarshalText(text []byte) error { + if len(text) == 0 { + *h = nil + return nil + } + result := make([]byte, hex.DecodedLen(len(text))) + _, err := hex.Decode(result, text) + if err != nil { + return err + } + *h = result + return nil +} diff --git a/chezmoi2/internal/chezmoi/hexbytes_test.go b/chezmoi2/internal/chezmoi/hexbytes_test.go new file mode 100644 index 000000000000..bfbd6f44b1f5 --- /dev/null +++ b/chezmoi2/internal/chezmoi/hexbytes_test.go @@ -0,0 +1,45 @@ +package chezmoi + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHexBytes(t *testing.T) { + for i, tc := range []struct { + b hexBytes + expectedStr string + }{ + { + b: nil, + expectedStr: "\"\"\n", + }, + { + b: []byte{0}, + expectedStr: "\"00\"\n", + }, + { + b: []byte{0, 1, 2, 3}, + expectedStr: "\"00010203\"\n", + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + for _, format := range []Format{ + jsonFormat{}, + yamlFormat{}, + } { + t.Run(format.Name(), func(t *testing.T) { + actual, err := format.Marshal(tc.b) + require.NoError(t, err) + assert.Equal(t, []byte(tc.expectedStr), actual) + var actualB hexBytes + require.NoError(t, format.Unmarshal(actual, &actualB)) + assert.Equal(t, tc.b, actualB) + }) + } + }) + } +} diff --git a/chezmoi2/internal/chezmoi/includeset.go b/chezmoi2/internal/chezmoi/includeset.go new file mode 100644 index 000000000000..a1a1ffc2cbbb --- /dev/null +++ b/chezmoi2/internal/chezmoi/includeset.go @@ -0,0 +1,164 @@ +package chezmoi + +// FIXME Add IncludeEncrypted + +import ( + "fmt" + "os" + "strings" +) + +// An IncludeSet controls what types of entries to include. It parses and prints +// as a comma-separated list of strings, but is internally represented as a +// bitmask. *IncludeSet implements the github.com/spf13/pflag.Value interface. +type IncludeSet struct { + bits IncludeBits +} + +// An IncludeBits is a bitmask of entries to include. +type IncludeBits int + +// Include bits. +const ( + IncludeAbsent IncludeBits = 1 << iota + IncludeDirs + IncludeFiles + IncludeScripts + IncludeSymlinks + + // IncludeAll is all include bits. + IncludeAll IncludeBits = IncludeAbsent | IncludeDirs | IncludeFiles | IncludeScripts | IncludeSymlinks + + // includeNone is no include bits. + includeNone IncludeBits = 0 +) + +// includeBits is a map from human-readable strings to IncludeBits. +var includeBits = map[string]IncludeBits{ + "a": IncludeAbsent, + "absent": IncludeAbsent, + "all": IncludeAll, + "d": IncludeDirs, + "dirs": IncludeDirs, + "f": IncludeFiles, + "files": IncludeFiles, + "scripts": IncludeScripts, + "s": IncludeSymlinks, + "symlinks": IncludeSymlinks, +} + +// NewIncludeSet returns a new IncludeSet. +func NewIncludeSet(bits IncludeBits) *IncludeSet { + return &IncludeSet{ + bits: bits, + } +} + +// IncludeActualStateEntry returns true if actualStateEntry should be included. +func (s *IncludeSet) IncludeActualStateEntry(actualStateEntry ActualStateEntry) bool { + switch actualStateEntry.(type) { + case *ActualStateDir: + return s.bits&IncludeDirs != 0 + case *ActualStateFile: + return s.bits&IncludeFiles != 0 + case *ActualStateSymlink: + return s.bits&IncludeSymlinks != 0 + default: + return false + } +} + +// IncludeFileInfo returns true if info should be included. +func (s *IncludeSet) IncludeFileInfo(info os.FileInfo) bool { + switch { + case info.IsDir(): + return s.bits&IncludeDirs != 0 + case info.Mode().IsRegular(): + return s.bits&IncludeFiles != 0 + case info.Mode()&os.ModeType == os.ModeSymlink: + return s.bits&IncludeSymlinks != 0 + default: + return false + } +} + +// IncludeTargetStateEntry returns true if targetStateEntry should be included. +func (s *IncludeSet) IncludeTargetStateEntry(targetStateEntry TargetStateEntry) bool { + switch targetStateEntry.(type) { + case *TargetStateAbsent: + return s.bits&IncludeAbsent != 0 + case *TargetStateDir: + return s.bits&IncludeDirs != 0 + case *TargetStateFile: + return s.bits&IncludeFiles != 0 + case *TargetStatePresent: + return s.bits&IncludeFiles != 0 + case *TargetStateRenameDir: + return s.bits&IncludeDirs != 0 + case *TargetStateScript: + return s.bits&IncludeScripts != 0 + case *TargetStateSymlink: + return s.bits&IncludeSymlinks != 0 + default: + return false + } +} + +// Set implements github.com/spf13/pflag.Value.Set. +func (s *IncludeSet) Set(str string) error { + if str == "none" { + s.bits = includeNone + return nil + } + + var bits IncludeBits + for _, element := range strings.Split(str, ",") { + if element == "" { + continue + } + exclude := false + if strings.HasPrefix(element, "!") { + exclude = true + element = element[1:] + } + bit, ok := includeBits[element] + if !ok { + return fmt.Errorf("%s: unknown include element", element) + } + if exclude { + bits &^= bit + } else { + bits |= bit + } + } + s.bits = bits + return nil +} + +func (s *IncludeSet) String() string { + //nolint:exhaustive + switch s.bits { + case IncludeAll: + return "all" + case includeNone: + return "none" + } + var elements []string + for i, element := range []string{ + "absent", + "dirs", + "files", + "scripts", + "symlinks", + } { + if s.bits&(1< 0 { + path = strings.ToUpper(path[:n]) + path[n:] + } + return AbsPath(filepath.ToSlash(path)), nil +} + +// expandTilde expands a leading tilde in path. +func expandTilde(path string, homeDirAbsPath AbsPath) string { + switch { + case path == "~": + return string(homeDirAbsPath) + case len(path) >= 2 && path[0] == '~' && isSlash(path[1]): + return string(homeDirAbsPath.Join(RelPath(path[2:]))) + default: + return path + } +} + +// volumeNameLen returns length of the leading volume name on Windows. It +// returns 0 elsewhere. +func volumeNameLen(path string) int { + if len(path) < 2 { + return 0 + } + // with drive letter + c := path[0] + if path[1] == ':' && ('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z') { + return 2 + } + // is it UNC? https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx + if l := len(path); l >= 5 && isSlash(path[0]) && isSlash(path[1]) && + !isSlash(path[2]) && path[2] != '.' { + // first, leading `\\` and next shouldn't be `\`. its server name. + for n := 3; n < l-1; n++ { + // second, next '\' shouldn't be repeated. + if isSlash(path[n]) { + n++ + // third, following something characters. its share name. + if !isSlash(path[n]) { + if path[n] == '.' { + break + } + for ; n < l; n++ { + if isSlash(path[n]) { + break + } + } + return n + } + break + } + } + } + return 0 +} + +// volumeNameToUpper returns path with the volume name converted to uppercase. +func volumeNameToUpper(path string) string { + if n := volumeNameLen(path); n > 0 { + return strings.ToUpper(path[:n]) + path[n:] + } + return path +} diff --git a/chezmoi2/internal/chezmoi/patternset.go b/chezmoi2/internal/chezmoi/patternset.go new file mode 100644 index 000000000000..280a09d318b0 --- /dev/null +++ b/chezmoi2/internal/chezmoi/patternset.go @@ -0,0 +1,108 @@ +package chezmoi + +import ( + "path/filepath" + "sort" + + "github.com/bmatcuk/doublestar/v3" + vfs "github.com/twpayne/go-vfs" +) + +// A stringSet is a set of strings. +type stringSet map[string]struct{} + +// An patternSet is a set of patterns. +type patternSet struct { + includePatterns stringSet + excludePatterns stringSet +} + +// newPatternSet returns a new patternSet. +func newPatternSet() *patternSet { + return &patternSet{ + includePatterns: newStringSet(), + excludePatterns: newStringSet(), + } +} + +// add adds a pattern to ps. +func (ps *patternSet) add(pattern string, include bool) error { + if _, err := doublestar.Match(pattern, ""); err != nil { + return err + } + if include { + ps.includePatterns.add(pattern) + } else { + ps.excludePatterns.add(pattern) + } + return nil +} + +// glob returns all matches in fs. +func (ps *patternSet) glob(fs vfs.FS, prefix string) ([]string, error) { + // FIXME use AbsPath and RelPath + vos := doubleStarOS{FS: fs} + allMatches := newStringSet() + for includePattern := range ps.includePatterns { + matches, err := doublestar.GlobOS(vos, prefix+includePattern) + if err != nil { + return nil, err + } + allMatches.add(matches...) + } + for match := range allMatches { + for excludePattern := range ps.excludePatterns { + exclude, err := doublestar.PathMatchOS(vos, prefix+excludePattern, match) + if err != nil { + return nil, err + } + if exclude { + delete(allMatches, match) + } + } + } + matchesSlice := allMatches.elements() + for i, match := range matchesSlice { + matchesSlice[i] = mustTrimPrefix(filepath.ToSlash(match), prefix) + } + sort.Strings(matchesSlice) + return matchesSlice, nil +} + +// match returns if name matches any pattern in ps. +func (ps *patternSet) match(name string) bool { + for pattern := range ps.excludePatterns { + if ok, _ := doublestar.Match(pattern, name); ok { + return false + } + } + for pattern := range ps.includePatterns { + if ok, _ := doublestar.Match(pattern, name); ok { + return true + } + } + return false +} + +// newStringSet returns a new StringSet containing elements. +func newStringSet(elements ...string) stringSet { + s := make(stringSet) + s.add(elements...) + return s +} + +// add adds elements to s. +func (s stringSet) add(elements ...string) { + for _, element := range elements { + s[element] = struct{}{} + } +} + +// elements returns all the elements of s. +func (s stringSet) elements() []string { + elements := make([]string, 0, len(s)) + for element := range s { + elements = append(elements, element) + } + return elements +} diff --git a/chezmoi2/internal/chezmoi/patternset_test.go b/chezmoi2/internal/chezmoi/patternset_test.go new file mode 100644 index 000000000000..778de13a8fe3 --- /dev/null +++ b/chezmoi2/internal/chezmoi/patternset_test.go @@ -0,0 +1,148 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestPatternSet(t *testing.T) { + for _, tc := range []struct { + name string + ps *patternSet + expectMatches map[string]bool + }{ + { + name: "empty", + ps: newPatternSet(), + expectMatches: map[string]bool{ + "foo": false, + }, + }, + { + name: "exact", + ps: mustNewPatternSet(t, map[string]bool{ + "foo": true, + }), + expectMatches: map[string]bool{ + "foo": true, + "bar": false, + }, + }, + { + name: "wildcard", + ps: mustNewPatternSet(t, map[string]bool{ + "b*": true, + }), + expectMatches: map[string]bool{ + "foo": false, + "bar": true, + "baz": true, + }, + }, + { + name: "exclude", + ps: mustNewPatternSet(t, map[string]bool{ + "b*": true, + "baz": false, + }), + expectMatches: map[string]bool{ + "foo": false, + "bar": true, + "baz": false, + }, + }, + { + name: "doublestar", + ps: mustNewPatternSet(t, map[string]bool{ + "**/foo": true, + }), + expectMatches: map[string]bool{ + "foo": true, + "bar/foo": true, + "baz/bar/foo": true, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + for s, expectMatch := range tc.expectMatches { + assert.Equal(t, expectMatch, tc.ps.match(s)) + } + }) + } +} + +func TestPatternSetGlob(t *testing.T) { + for _, tc := range []struct { + name string + ps *patternSet + root interface{} + expectedMatches []string + }{ + { + name: "empty", + ps: newPatternSet(), + root: nil, + expectedMatches: []string{}, + }, + { + name: "simple", + ps: mustNewPatternSet(t, map[string]bool{ + "f*": true, + }), + root: map[string]interface{}{ + "foo": "", + }, + expectedMatches: []string{ + "foo", + }, + }, + { + name: "include_exclude", + ps: mustNewPatternSet(t, map[string]bool{ + "b*": true, + "*z": false, + }), + root: map[string]interface{}{ + "bar": "", + "baz": "", + }, + expectedMatches: []string{ + "bar", + }, + }, + { + name: "doublestar", + ps: mustNewPatternSet(t, map[string]bool{ + "**/f*": true, + }), + root: map[string]interface{}{ + "dir1/dir2/foo": "", + }, + expectedMatches: []string{ + "dir1/dir2/foo", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fs vfs.FS) { + actualMatches, err := tc.ps.glob(fs, "/") + require.NoError(t, err) + assert.Equal(t, tc.expectedMatches, actualMatches) + }) + }) + } +} + +func mustNewPatternSet(t *testing.T, patterns map[string]bool) *patternSet { + t.Helper() + ps := newPatternSet() + for pattern, exclude := range patterns { + require.NoError(t, ps.add(pattern, exclude)) + } + return ps +} diff --git a/chezmoi2/internal/chezmoi/persistentstate.go b/chezmoi2/internal/chezmoi/persistentstate.go new file mode 100644 index 000000000000..45a1a4e5df8f --- /dev/null +++ b/chezmoi2/internal/chezmoi/persistentstate.go @@ -0,0 +1,81 @@ +package chezmoi + +var ( + // entryStateBucket is the bucket for recording the entry states. + entryStateBucket = []byte("entryState") + + // scriptStateBucket is the bucket for recording the state of run once + // scripts. + scriptStateBucket = []byte("scriptState") + + stateFormat = jsonFormat{} +) + +// A PersistentState is a persistent state. +type PersistentState interface { + Close() error + CopyTo(s PersistentState) error + Delete(bucket, key []byte) error + ForEach(bucket []byte, fn func(k, v []byte) error) error + Get(bucket, key []byte) ([]byte, error) + Set(bucket, key, value []byte) error +} + +// PersistentStateData returns the structured data in s. +func PersistentStateData(s PersistentState) (interface{}, error) { + entryStateData, err := persistentStateBucketData(s, entryStateBucket) + if err != nil { + return nil, err + } + scriptStateData, err := persistentStateBucketData(s, scriptStateBucket) + if err != nil { + return nil, err + } + return struct { + EntryState interface{} `json:"entryState" toml:"entryState" yaml:"entryState"` + ScriptState interface{} `json:"scriptState" toml:"scriptState" yaml:"scriptState"` + }{ + EntryState: entryStateData, + ScriptState: scriptStateData, + }, nil +} + +// persistentStateBucketData returns the state data in bucket in s. +func persistentStateBucketData(s PersistentState, bucket []byte) (map[string]interface{}, error) { + result := make(map[string]interface{}) + if err := s.ForEach(bucket, func(k, v []byte) error { + var value map[string]interface{} + if err := stateFormat.Unmarshal(v, &value); err != nil { + return err + } + result[string(k)] = value + return nil + }); err != nil { + return nil, err + } + return result, nil +} + +// persistentStateGet gets the value associated with key in bucket in s, if it exists. +func persistentStateGet(s PersistentState, bucket, key []byte, value interface{}) (bool, error) { + data, err := s.Get(bucket, key) + if err != nil { + return false, err + } + if data == nil { + return false, nil + } + if err := stateFormat.Unmarshal(data, value); err != nil { + return false, err + } + return true, nil +} + +// persistentStateSet sets the value associated with key in bucket in s. +func persistentStateSet(s PersistentState, bucket, key []byte, value interface{}) error { + data, err := stateFormat.Marshal(value) + if err != nil { + return err + } + return s.Set(bucket, key, data) +} diff --git a/chezmoi2/internal/chezmoi/readonlysystem.go b/chezmoi2/internal/chezmoi/readonlysystem.go new file mode 100644 index 000000000000..36dc8299ae61 --- /dev/null +++ b/chezmoi2/internal/chezmoi/readonlysystem.go @@ -0,0 +1,66 @@ +package chezmoi + +import ( + "os" + "os/exec" + + vfs "github.com/twpayne/go-vfs" +) + +// A ReadOnlySystem is a system that may only be read from. +type ReadOnlySystem struct { + noUpdateSystemMixin + system System +} + +// NewReadOnlySystem returns a new ReadOnlySystem that wraps system. +func NewReadOnlySystem(system System) *ReadOnlySystem { + return &ReadOnlySystem{ + system: system, + } +} + +// Glob implements System.Glob. +func (s *ReadOnlySystem) Glob(pattern string) ([]string, error) { + return s.system.Glob(pattern) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *ReadOnlySystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return s.system.IdempotentCmdOutput(cmd) +} + +// Lstat implements System.Lstat. +func (s *ReadOnlySystem) Lstat(filename AbsPath) (os.FileInfo, error) { + return s.system.Lstat(filename) +} + +// RawPath implements System.RawPath. +func (s *ReadOnlySystem) RawPath(path AbsPath) (AbsPath, error) { + return s.system.RawPath(path) +} + +// ReadDir implements System.ReadDir. +func (s *ReadOnlySystem) ReadDir(dirname AbsPath) ([]os.FileInfo, error) { + return s.system.ReadDir(dirname) +} + +// ReadFile implements System.ReadFile. +func (s *ReadOnlySystem) ReadFile(filename AbsPath) ([]byte, error) { + return s.system.ReadFile(filename) +} + +// Readlink implements System.Readlink. +func (s *ReadOnlySystem) Readlink(name AbsPath) (string, error) { + return s.system.Readlink(name) +} + +// Stat implements System.Stat. +func (s *ReadOnlySystem) Stat(name AbsPath) (os.FileInfo, error) { + return s.system.Stat(name) +} + +// UnderlyingFS implements System.UnderlyingFS. +func (s *ReadOnlySystem) UnderlyingFS() vfs.FS { + return s.system.UnderlyingFS() +} diff --git a/chezmoi2/internal/chezmoi/readonlysystem_test.go b/chezmoi2/internal/chezmoi/readonlysystem_test.go new file mode 100644 index 000000000000..df73065bfe04 --- /dev/null +++ b/chezmoi2/internal/chezmoi/readonlysystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &ReadOnlySystem{} diff --git a/chezmoi2/internal/chezmoi/realsystem.go b/chezmoi2/internal/chezmoi/realsystem.go new file mode 100644 index 000000000000..1815941dd072 --- /dev/null +++ b/chezmoi2/internal/chezmoi/realsystem.go @@ -0,0 +1,120 @@ +package chezmoi + +import ( + "io/ioutil" + "os" + "os/exec" + "runtime" + + "github.com/bmatcuk/doublestar/v3" + "github.com/rs/zerolog/log" + vfs "github.com/twpayne/go-vfs" + "go.uber.org/multierr" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoilog" +) + +// Glob implements System.Glob. +func (s *RealSystem) Glob(pattern string) ([]string, error) { + return doublestar.GlobOS(doubleStarOS{FS: s.UnderlyingFS()}, pattern) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *RealSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return chezmoilog.LogCmdOutput(log.Logger, cmd) +} + +// Lstat implements System.Lstat. +func (s *RealSystem) Lstat(filename AbsPath) (os.FileInfo, error) { + return s.fs.Lstat(string(filename)) +} + +// Mkdir implements System.Mkdir. +func (s *RealSystem) Mkdir(name AbsPath, perm os.FileMode) error { + return s.fs.Mkdir(string(name), perm) +} + +// PathSeparator implements doublestar.OS.PathSeparator. +func (s *RealSystem) PathSeparator() rune { + return '/' +} + +// RawPath implements System.RawPath. +func (s *RealSystem) RawPath(absPath AbsPath) (AbsPath, error) { + rawAbsPath, err := s.fs.RawPath(string(absPath)) + if err != nil { + return "", err + } + return AbsPath(rawAbsPath), nil +} + +// ReadDir implements System.ReadDir. +func (s *RealSystem) ReadDir(dirname AbsPath) ([]os.FileInfo, error) { + return s.fs.ReadDir(string(dirname)) +} + +// ReadFile implements System.ReadFile. +func (s *RealSystem) ReadFile(filename AbsPath) ([]byte, error) { + return s.fs.ReadFile(string(filename)) +} + +// RemoveAll implements System.RemoveAll. +func (s *RealSystem) RemoveAll(name AbsPath) error { + return s.fs.RemoveAll(string(name)) +} + +// Rename implements System.Rename. +func (s *RealSystem) Rename(oldpath, newpath AbsPath) error { + return s.fs.Rename(string(oldpath), string(newpath)) +} + +// RunCmd implements System.RunCmd. +func (s *RealSystem) RunCmd(cmd *exec.Cmd) error { + return chezmoilog.LogCmdRun(log.Logger, cmd) +} + +// RunScript implements System.RunScript. +func (s *RealSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte) (err error) { + // Write the temporary script file. Put the randomness at the front of the + // filename to preserve any file extension for Windows scripts. + f, err := ioutil.TempFile("", "*."+scriptname.Base()) + if err != nil { + return + } + defer func() { + err = multierr.Append(err, os.RemoveAll(f.Name())) + }() + + // Make the script private before writing it in case it contains any + // secrets. + if runtime.GOOS != "windows" { + if err = f.Chmod(0o700); err != nil { + return + } + } + _, err = f.Write(data) + err = multierr.Append(err, f.Close()) + if err != nil { + return + } + + // Run the temporary script file. + //nolint:gosec + cmd := exec.Command(f.Name()) + cmd.Dir = string(dir) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = s.RunCmd(cmd) + return +} + +// Stat implements System.Stat. +func (s *RealSystem) Stat(name AbsPath) (os.FileInfo, error) { + return s.fs.Stat(string(name)) +} + +// UnderlyingFS implements System.UnderlyingFS. +func (s *RealSystem) UnderlyingFS() vfs.FS { + return s.fs +} diff --git a/chezmoi2/internal/chezmoi/realsystem_test.go b/chezmoi2/internal/chezmoi/realsystem_test.go new file mode 100644 index 000000000000..4eb770063954 --- /dev/null +++ b/chezmoi2/internal/chezmoi/realsystem_test.go @@ -0,0 +1,72 @@ +package chezmoi + +import ( + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +var _ System = &RealSystem{} + +func TestRealSystemGlob(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]interface{}{ + "/home/user": map[string]interface{}{ + "bar": "", + "baz": "", + "foo": "", + "dir/bar": "", + "dir/foo": "", + "dir/subdir/foo": "", + }, + }, func(fs vfs.FS) { + s := NewRealSystem(fs) + for _, tc := range []struct { + pattern string + expectedMatches []string + }{ + { + pattern: "/home/user/foo", + expectedMatches: []string{ + "/home/user/foo", + }, + }, + { + pattern: "/home/user/**/foo", + expectedMatches: []string{ + "/home/user/dir/foo", + "/home/user/dir/subdir/foo", + "/home/user/foo", + }, + }, + { + pattern: "/home/user/**/ba*", + expectedMatches: []string{ + "/home/user/bar", + "/home/user/baz", + "/home/user/dir/bar", + }, + }, + } { + t.Run(tc.pattern, func(t *testing.T) { + actualMatches, err := s.Glob(tc.pattern) + require.NoError(t, err) + sort.Strings(actualMatches) + assert.Equal(t, tc.expectedMatches, pathsToSlashes(actualMatches)) + }) + } + }) +} + +func pathsToSlashes(paths []string) []string { + result := make([]string, 0, len(paths)) + for _, path := range paths { + result = append(result, filepath.ToSlash(path)) + } + return result +} diff --git a/chezmoi2/internal/chezmoi/realsystem_unix.go b/chezmoi2/internal/chezmoi/realsystem_unix.go new file mode 100644 index 000000000000..93f074636db5 --- /dev/null +++ b/chezmoi2/internal/chezmoi/realsystem_unix.go @@ -0,0 +1,119 @@ +// +build !windows + +package chezmoi + +import ( + "errors" + "os" + "syscall" + + "github.com/google/renameio" + vfs "github.com/twpayne/go-vfs" + "go.uber.org/multierr" +) + +// An RealSystem is a System that writes to a filesystem and executes scripts. +type RealSystem struct { + fs vfs.FS + devCache map[AbsPath]uint // devCache maps directories to device numbers. + tempDirCache map[uint]string // tempDirCache maps device numbers to renameio temporary directories. +} + +// NewRealSystem returns a System that acts on fs. +func NewRealSystem(fs vfs.FS) *RealSystem { + return &RealSystem{ + fs: fs, + devCache: make(map[AbsPath]uint), + tempDirCache: make(map[uint]string), + } +} + +// Chmod implements System.Chmod. +func (s *RealSystem) Chmod(name AbsPath, mode os.FileMode) error { + return s.fs.Chmod(string(name), mode) +} + +// Readlink implements System.Readlink. +func (s *RealSystem) Readlink(name AbsPath) (string, error) { + return s.fs.Readlink(string(name)) +} + +// WriteFile implements System.WriteFile. +func (s *RealSystem) WriteFile(filename AbsPath, data []byte, perm os.FileMode) error { + // Special case: if writing to the real filesystem, use + // github.com/google/renameio. + if s.fs == vfs.OSFS { + dir := filename.Dir() + dev, ok := s.devCache[dir] + if !ok { + info, err := s.Stat(dir) + if err != nil { + return err + } + statT, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("os.FileInfo.Sys() cannot be converted to a *syscall.Stat_t") + } + dev = uint(statT.Dev) + s.devCache[dir] = dev + } + tempDir, ok := s.tempDirCache[dev] + if !ok { + tempDir = renameio.TempDir(string(dir)) + s.tempDirCache[dev] = tempDir + } + t, err := renameio.TempFile(tempDir, string(filename)) + if err != nil { + return err + } + defer func() { + _ = t.Cleanup() + }() + if err := t.Chmod(perm); err != nil { + return err + } + if _, err := t.Write(data); err != nil { + return err + } + return t.CloseAtomicallyReplace() + } + + return writeFile(s.fs, filename, data, perm) +} + +// WriteSymlink implements System.WriteSymlink. +func (s *RealSystem) WriteSymlink(oldname string, newname AbsPath) error { + // Special case: if writing to the real filesystem, use + // github.com/google/renameio. + if s.fs == vfs.OSFS { + return renameio.Symlink(oldname, string(newname)) + } + if err := s.fs.RemoveAll(string(newname)); err != nil && !os.IsNotExist(err) { + return err + } + return s.fs.Symlink(oldname, string(newname)) +} + +// writeFile is like ioutil.writeFile but always sets perm before writing data. +// ioutil.writeFile only sets the permissions when creating a new file. We need +// to ensure permissions, so we use our own implementation. +func writeFile(fs vfs.FS, filename AbsPath, data []byte, perm os.FileMode) (err error) { + // Create a new file, or truncate any existing one. + f, err := fs.OpenFile(string(filename), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return + } + defer func() { + err = multierr.Append(err, f.Close()) + }() + + // 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 err = f.Chmod(perm); err != nil { + return + } + + _, err = f.Write(data) + return +} diff --git a/chezmoi2/internal/chezmoi/realsystem_windows.go b/chezmoi2/internal/chezmoi/realsystem_windows.go new file mode 100644 index 000000000000..a9cb82a52a33 --- /dev/null +++ b/chezmoi2/internal/chezmoi/realsystem_windows.go @@ -0,0 +1,47 @@ +package chezmoi + +import ( + "os" + "path/filepath" + + vfs "github.com/twpayne/go-vfs" +) + +// An RealSystem is a System that writes to a filesystem and executes scripts. +type RealSystem struct { + fs vfs.FS +} + +// NewRealSystem returns a System that acts on fs. +func NewRealSystem(fs vfs.FS) *RealSystem { + return &RealSystem{ + fs: fs, + } +} + +// Chmod implements System.Chmod. +func (s *RealSystem) Chmod(name AbsPath, mode os.FileMode) error { + return nil +} + +// Readlink implements System.Readlink. +func (s *RealSystem) Readlink(name AbsPath) (string, error) { + linkname, err := s.fs.Readlink(string(name)) + if err != nil { + return "", err + } + return filepath.ToSlash(linkname), nil +} + +// WriteFile implements System.WriteFile. +func (s *RealSystem) WriteFile(filename AbsPath, data []byte, perm os.FileMode) error { + return s.fs.WriteFile(string(filename), data, perm) +} + +// WriteSymlink implements System.WriteSymlink. +func (s *RealSystem) WriteSymlink(oldname string, newname AbsPath) error { + if err := s.fs.RemoveAll(string(newname)); err != nil && !os.IsNotExist(err) { + return err + } + return s.fs.Symlink(filepath.FromSlash(oldname), string(newname)) +} diff --git a/chezmoi2/internal/chezmoi/recursivemerge.go b/chezmoi2/internal/chezmoi/recursivemerge.go new file mode 100644 index 000000000000..86f2fd94ab45 --- /dev/null +++ b/chezmoi2/internal/chezmoi/recursivemerge.go @@ -0,0 +1,40 @@ +package chezmoi + +// recursiveCopy returns a recursive copy of v. +func recursiveCopy(v interface{}) interface{} { + m, ok := v.(map[string]interface{}) + if !ok { + return v + } + c := make(map[string]interface{}) + for key, value := range m { + if mapValue, ok := value.(map[string]interface{}); ok { + c[key] = recursiveCopy(mapValue) + } else { + c[key] = value + } + } + return c +} + +// recursiveMerge recursively merges maps in source into dest. +func recursiveMerge(dest, source map[string]interface{}) { + for key, sourceValue := range source { + destValue, ok := dest[key] + if !ok { + dest[key] = recursiveCopy(sourceValue) + continue + } + destMap, ok := destValue.(map[string]interface{}) + if !ok || destMap == nil { + dest[key] = recursiveCopy(sourceValue) + continue + } + sourceMap, ok := sourceValue.(map[string]interface{}) + if !ok { + dest[key] = recursiveCopy(sourceValue) + continue + } + recursiveMerge(destMap, sourceMap) + } +} diff --git a/chezmoi2/internal/chezmoi/recursivemerge_test.go b/chezmoi2/internal/chezmoi/recursivemerge_test.go new file mode 100644 index 000000000000..4c07fc48e9f7 --- /dev/null +++ b/chezmoi2/internal/chezmoi/recursivemerge_test.go @@ -0,0 +1,68 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRecursiveMerge(t *testing.T) { + for _, tc := range []struct { + dest map[string]interface{} + source map[string]interface{} + expectedDest map[string]interface{} + }{ + { + dest: map[string]interface{}{}, + source: nil, + expectedDest: map[string]interface{}{}, + }, + { + dest: map[string]interface{}{ + "a": 1, + "b": 2, + "c": map[string]interface{}{ + "d": 4, + "e": 5, + }, + "f": map[string]interface{}{ + "g": 6, + }, + }, + source: map[string]interface{}{ + "b": 20, + "c": map[string]interface{}{ + "e": 50, + "f": 60, + }, + "f": 60, + }, + expectedDest: map[string]interface{}{ + "a": 1, + "b": 20, + "c": map[string]interface{}{ + "d": 4, + "e": 50, + "f": 60, + }, + "f": 60, + }, + }, + } { + recursiveMerge(tc.dest, tc.source) + assert.Equal(t, tc.expectedDest, tc.dest) + } +} + +func TestRecursiveMergeCopies(t *testing.T) { + original := map[string]interface{}{ + "key": "initialValue", + } + dest := make(map[string]interface{}) + recursiveMerge(dest, original) + recursiveMerge(dest, map[string]interface{}{ + "key": "mergedValue", + }) + assert.Equal(t, "mergedValue", dest["key"]) + assert.Equal(t, "initialValue", original["key"]) +} diff --git a/chezmoi2/internal/chezmoi/sourcerelpath.go b/chezmoi2/internal/chezmoi/sourcerelpath.go new file mode 100644 index 000000000000..afe914624d7f --- /dev/null +++ b/chezmoi2/internal/chezmoi/sourcerelpath.go @@ -0,0 +1,92 @@ +package chezmoi + +import ( + "strings" +) + +// A SourceRelPath is a relative path to an entry in the source state. +type SourceRelPath struct { + relPath RelPath + isDir bool +} + +// SourceRelPaths is a slice of SourceRelPaths that implements sort.Interface. +type SourceRelPaths []SourceRelPath + +func (ps SourceRelPaths) Len() int { return len(ps) } +func (ps SourceRelPaths) Less(i, j int) bool { return string(ps[i].relPath) < string(ps[j].relPath) } +func (ps SourceRelPaths) Swap(i, j int) { ps[i], ps[j] = ps[j], ps[i] } + +// NewSourceRelDirPath returns a new SourceRelPath for a directory. +func NewSourceRelDirPath(relPath RelPath) SourceRelPath { + return SourceRelPath{ + relPath: relPath, + isDir: true, + } +} + +// NewSourceRelPath returns a new SourceRelPath. +func NewSourceRelPath(relPath RelPath) SourceRelPath { + return SourceRelPath{ + relPath: relPath, + } +} + +// Dir returns p's directory. +func (p SourceRelPath) Dir() SourceRelPath { + return SourceRelPath{ + relPath: p.relPath.Dir(), + isDir: true, + } +} + +// Empty returns true if p is empty. +func (p SourceRelPath) Empty() bool { + return p == SourceRelPath{} +} + +// Join appends elems to p. +func (p SourceRelPath) Join(elems ...SourceRelPath) SourceRelPath { + elemRelPaths := make(RelPaths, 0, len(elems)) + for _, elem := range elems { + elemRelPaths = append(elemRelPaths, elem.relPath) + } + return SourceRelPath{ + relPath: p.relPath.Join(elemRelPaths...), + } +} + +// RelPath returns p as a relative path. +func (p SourceRelPath) RelPath() RelPath { + return p.relPath +} + +// Split returns the p's file and directory. +func (p SourceRelPath) Split() (SourceRelPath, SourceRelPath) { + dir, file := p.relPath.Split() + return NewSourceRelDirPath(dir), NewSourceRelPath(file) +} + +func (p SourceRelPath) String() string { + return string(p.relPath) +} + +// TargetRelPath returns the relative path of p's target. +func (p SourceRelPath) TargetRelPath() RelPath { + sourceNames := strings.Split(string(p.relPath), "/") + relPathNames := make([]string, 0, len(sourceNames)) + if p.isDir { + for _, sourceName := range sourceNames { + dirAttr := parseDirAttr(sourceName) + relPathNames = append(relPathNames, dirAttr.TargetName) + } + } else { + for _, sourceName := range sourceNames[:len(sourceNames)-1] { + dirAttr := parseDirAttr(sourceName) + relPathNames = append(relPathNames, dirAttr.TargetName) + } + fileAttr := parseFileAttr(sourceNames[len(sourceNames)-1]) + relPathNames = append(relPathNames, fileAttr.TargetName) + } + return RelPath(strings.Join(relPathNames, "/")) +} diff --git a/chezmoi2/internal/chezmoi/sourcerelpath_test.go b/chezmoi2/internal/chezmoi/sourcerelpath_test.go new file mode 100644 index 000000000000..7c6feb06fc7a --- /dev/null +++ b/chezmoi2/internal/chezmoi/sourcerelpath_test.go @@ -0,0 +1,63 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSourceRelPath(t *testing.T) { + // FIXME test Split + for _, tc := range []struct { + name string + sourceStatePath SourceRelPath + expectedDirPath SourceRelPath + expectedTargetRelPath RelPath + }{ + { + name: "empty", + expectedDirPath: NewSourceRelDirPath("."), + }, + { + name: "dir", + sourceStatePath: NewSourceRelDirPath("dir"), + expectedDirPath: NewSourceRelDirPath("."), + expectedTargetRelPath: "dir", + }, + { + name: "exact_dir", + sourceStatePath: NewSourceRelDirPath("exact_dir"), + expectedDirPath: NewSourceRelDirPath("."), + expectedTargetRelPath: "dir", + }, + { + name: "exact_dir_private_dir", + sourceStatePath: NewSourceRelDirPath("exact_dir/private_dir"), + expectedDirPath: NewSourceRelDirPath("exact_dir"), + expectedTargetRelPath: "dir/dir", + }, + { + name: "file", + sourceStatePath: NewSourceRelPath("file"), + expectedDirPath: NewSourceRelDirPath("."), + expectedTargetRelPath: "file", + }, + { + name: "dot_file", + sourceStatePath: NewSourceRelPath("dot_file"), + expectedDirPath: NewSourceRelDirPath("."), + expectedTargetRelPath: ".file", + }, + { + name: "exact_dir_executable_file", + sourceStatePath: NewSourceRelPath("exact_dir/executable_file"), + expectedDirPath: NewSourceRelDirPath("exact_dir"), + expectedTargetRelPath: "dir/file", + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expectedDirPath, tc.sourceStatePath.Dir()) + assert.Equal(t, tc.expectedTargetRelPath, tc.sourceStatePath.TargetRelPath()) + }) + } +} diff --git a/chezmoi2/internal/chezmoi/sourcestate.go b/chezmoi2/internal/chezmoi/sourcestate.go new file mode 100644 index 000000000000..9b37eb4e0df3 --- /dev/null +++ b/chezmoi2/internal/chezmoi/sourcestate.go @@ -0,0 +1,998 @@ +package chezmoi + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "os" + "sort" + "strings" + "text/template" + + "github.com/coreos/go-semver/semver" + vfs "github.com/twpayne/go-vfs" + "go.uber.org/multierr" +) + +// A SourceState is a source state. +type SourceState struct { + entries map[RelPath]SourceStateEntry + system System + sourceDirAbsPath AbsPath + destDirAbsPath AbsPath + umask os.FileMode + encryption Encryption + ignore *patternSet + minVersion semver.Version + defaultTemplateDataFunc func() map[string]interface{} + userTemplateData map[string]interface{} + priorityTemplateData map[string]interface{} + templateData map[string]interface{} + templateFuncs template.FuncMap + templateOptions []string + templates map[string]*template.Template +} + +// A SourceStateOption sets an option on a source state. +type SourceStateOption func(*SourceState) + +// WithDestDir sets the destination directory. +func WithDestDir(destDirAbsPath AbsPath) SourceStateOption { + return func(s *SourceState) { + s.destDirAbsPath = destDirAbsPath + } +} + +// WithEncryption sets the encryption. +func WithEncryption(encryption Encryption) SourceStateOption { + return func(s *SourceState) { + s.encryption = encryption + } +} + +// WithPriorityTemplateData adds priority template data. +func WithPriorityTemplateData(priorityTemplateData map[string]interface{}) SourceStateOption { + return func(s *SourceState) { + recursiveMerge(s.priorityTemplateData, priorityTemplateData) + } +} + +// WithSourceDir sets the source directory. +func WithSourceDir(sourceDirAbsPath AbsPath) SourceStateOption { + return func(s *SourceState) { + s.sourceDirAbsPath = sourceDirAbsPath + } +} + +// WithSystem sets the system. +func WithSystem(system System) SourceStateOption { + return func(s *SourceState) { + s.system = system + } +} + +// WithDefaultTemplateDataFunc sets the default template data function. +func WithDefaultTemplateDataFunc(defaultTemplateDataFunc func() map[string]interface{}) SourceStateOption { + return func(s *SourceState) { + s.defaultTemplateDataFunc = defaultTemplateDataFunc + } +} + +// WithTemplateFuncs sets the template functions. +func WithTemplateFuncs(templateFuncs template.FuncMap) SourceStateOption { + return func(s *SourceState) { + s.templateFuncs = templateFuncs + } +} + +// WithTemplateOptions sets the template options. +func WithTemplateOptions(templateOptions []string) SourceStateOption { + return func(s *SourceState) { + s.templateOptions = templateOptions + } +} + +// WithUmask sets the umask. +func WithUmask(umask os.FileMode) SourceStateOption { + return func(s *SourceState) { + s.umask = umask + } +} + +// NewSourceState creates a new source state with the given options. +func NewSourceState(options ...SourceStateOption) *SourceState { + s := &SourceState{ + entries: make(map[RelPath]SourceStateEntry), + umask: GetUmask(), + encryption: NoEncryption{}, + ignore: newPatternSet(), + priorityTemplateData: make(map[string]interface{}), + userTemplateData: make(map[string]interface{}), + templateOptions: DefaultTemplateOptions, + } + for _, option := range options { + option(s) + } + return s +} + +// AddOptions are options to SourceState.Add. +type AddOptions struct { + AutoTemplate bool + Empty bool + Encrypt bool + Exact bool + Exists bool + Include *IncludeSet + RemoveDir RelPath + Template bool + umask os.FileMode +} + +// Add adds destAbsPathInfos to s. +func (s *SourceState) Add(sourceSystem System, persistentState PersistentState, destSystem System, destAbsPathInfos map[AbsPath]os.FileInfo, options *AddOptions) error { + type update struct { + destAbsPath AbsPath + entryState *EntryState + sourceRelPaths SourceRelPaths + } + + destAbsPaths := make(AbsPaths, 0, len(destAbsPathInfos)) + for destAbsPath := range destAbsPathInfos { + destAbsPaths = append(destAbsPaths, destAbsPath) + } + sort.Sort(destAbsPaths) + + updates := make([]update, 0, len(destAbsPathInfos)) + newSourceStateEntries := make(map[SourceRelPath]SourceStateEntry) + newSourceStateEntriesByTargetRelPath := make(map[RelPath]SourceStateEntry) + for _, destAbsPath := range destAbsPaths { + destAbsPathInfo := destAbsPathInfos[destAbsPath] + if !options.Include.IncludeFileInfo(destAbsPathInfo) { + continue + } + targetRelPath := destAbsPath.MustTrimDirPrefix(s.destDirAbsPath) + + // Find the target's parent directory in the source state. + var parentSourceRelPath SourceRelPath + if targetParentRelPath := targetRelPath.Dir(); targetParentRelPath == "." { + parentSourceRelPath = SourceRelPath{} + } else if parentEntry, ok := newSourceStateEntriesByTargetRelPath[targetParentRelPath]; ok { + parentSourceRelPath = parentEntry.SourceRelPath() + } else if parentEntry, ok := s.entries[targetParentRelPath]; ok { + parentSourceRelPath = parentEntry.SourceRelPath() + } else { + return fmt.Errorf("%s: parent directory not in source state", destAbsPath) + } + + actualStateEntry, err := NewActualStateEntry(destSystem, destAbsPath, destAbsPathInfo, nil) + if err != nil { + return err + } + newSourceStateEntry, err := s.sourceStateEntry(actualStateEntry, destAbsPath, destAbsPathInfo, parentSourceRelPath, options) + if err != nil { + return err + } + if newSourceStateEntry == nil { + continue + } + + sourceEntryRelPath := newSourceStateEntry.SourceRelPath() + + entryState, err := actualStateEntry.EntryState() + if err != nil { + return err + } + update := update{ + destAbsPath: destAbsPath, + entryState: entryState, + sourceRelPaths: SourceRelPaths{sourceEntryRelPath}, + } + + if oldSourceStateEntry, ok := s.entries[targetRelPath]; ok { + // If both the new and old source state entries are directories but the name has changed, + // rename to avoid losing the directory's contents. Otherwise, + // remove the old. + oldSourceEntryRelPath := oldSourceStateEntry.SourceRelPath() + if !oldSourceEntryRelPath.Empty() && oldSourceEntryRelPath != sourceEntryRelPath { + _, newIsDir := newSourceStateEntry.(*SourceStateDir) + _, oldIsDir := oldSourceStateEntry.(*SourceStateDir) + if newIsDir && oldIsDir { + newSourceStateEntry = &SourceStateRenameDir{ + oldSourceRelPath: oldSourceEntryRelPath, + newSourceRelPath: sourceEntryRelPath, + } + } else { + newSourceStateEntries[oldSourceEntryRelPath] = &SourceStateRemove{} + update.sourceRelPaths = append(update.sourceRelPaths, oldSourceEntryRelPath) + } + } + } + + newSourceStateEntries[sourceEntryRelPath] = newSourceStateEntry + newSourceStateEntriesByTargetRelPath[targetRelPath] = newSourceStateEntry + + updates = append(updates, update) + } + + entries := make(map[RelPath]SourceStateEntry) + for sourceRelPath, sourceStateEntry := range newSourceStateEntries { + entries[sourceRelPath.RelPath()] = sourceStateEntry + } + + // Simulate removing a directory by creating SourceStateRemove entries for + // all existing source state entries that are in options.RemoveDir and not + // in the new source state. + if options.RemoveDir != "" { + for targetRelPath, sourceStateEntry := range s.entries { + if !targetRelPath.HasDirPrefix(options.RemoveDir) { + continue + } + if _, ok := newSourceStateEntriesByTargetRelPath[targetRelPath]; ok { + continue + } + sourceRelPath := sourceStateEntry.SourceRelPath() + entries[sourceRelPath.RelPath()] = &SourceStateRemove{} + update := update{ + destAbsPath: s.destDirAbsPath.Join(targetRelPath), + entryState: &EntryState{ + Type: EntryStateTypeAbsent, + }, + sourceRelPaths: []SourceRelPath{sourceRelPath}, + } + updates = append(updates, update) + } + } + + targetSourceState := &SourceState{ + entries: entries, + } + + for _, update := range updates { + for _, sourceRelPath := range update.sourceRelPaths { + if err := targetSourceState.Apply(sourceSystem, NullPersistentState{}, s.sourceDirAbsPath, sourceRelPath.RelPath(), ApplyOptions{ + Include: options.Include, + Umask: options.umask, + }); err != nil { + return err + } + } + if err := persistentStateSet(persistentState, entryStateBucket, []byte(update.destAbsPath), update.entryState); err != nil { + return err + } + } + + return nil +} + +// AddDestAbsPathInfos adds an os.FileInfo to destAbsPathInfos for destAbsPath +// and any of its parents which are not already known. +func (s *SourceState) AddDestAbsPathInfos(destAbsPathInfos map[AbsPath]os.FileInfo, system System, destAbsPath AbsPath, info os.FileInfo) error { + for { + if _, err := destAbsPath.TrimDirPrefix(s.destDirAbsPath); err != nil { + return err + } + + if _, ok := destAbsPathInfos[destAbsPath]; ok { + return nil + } + + if info == nil { + var err error + info, err = system.Lstat(destAbsPath) + if err != nil { + return err + } + } + destAbsPathInfos[destAbsPath] = info + + parentAbsPath := destAbsPath.Dir() + if parentAbsPath == s.destDirAbsPath { + return nil + } + parentRelPath := parentAbsPath.MustTrimDirPrefix(s.destDirAbsPath) + if _, ok := s.entries[parentRelPath]; ok { + return nil + } + + destAbsPath = parentAbsPath + info = nil + } +} + +// A PreApplyFunc is called before a target is applied. +type PreApplyFunc func(targetRelPath RelPath, targetEntryState, lastWrittenEntryState, actualEntryState *EntryState) error + +// ApplyOptions are options to SourceState.ApplyAll and SourceState.ApplyOne. +type ApplyOptions struct { + IgnoreEncrypted bool + Include *IncludeSet + PreApplyFunc PreApplyFunc + Umask os.FileMode +} + +// Apply updates targetRelPath in targetDir in targetSystem to match s. +func (s *SourceState) Apply(targetSystem System, persistentState PersistentState, targetDir AbsPath, targetRelPath RelPath, options ApplyOptions) error { + sourceStateEntry := s.entries[targetRelPath] + + if options.IgnoreEncrypted { + if sourceStateFile, ok := sourceStateEntry.(*SourceStateFile); ok && sourceStateFile.Attr.Encrypted { + return nil + } + } + + targetStateEntry, err := sourceStateEntry.TargetStateEntry() + if err != nil { + return err + } + + if options.Include != nil && !options.Include.IncludeTargetStateEntry(targetStateEntry) { + return nil + } + + targetAbsPath := targetDir.Join(targetRelPath) + + targetEntryState, err := targetStateEntry.EntryState() + if err != nil { + return err + } + + switch skip, err := targetStateEntry.SkipApply(persistentState); { + case err != nil: + return err + case skip: + return nil + } + + actualStateEntry, err := NewActualStateEntry(targetSystem, targetAbsPath, nil, nil) + if err != nil { + return err + } + + if options.PreApplyFunc != nil { + var lastWrittenEntryState *EntryState + var entryState EntryState + ok, err := persistentStateGet(persistentState, entryStateBucket, []byte(targetAbsPath), &entryState) + if err != nil { + return err + } + if ok { + lastWrittenEntryState = &entryState + } + + actualEntryState, err := actualStateEntry.EntryState() + if err != nil { + return err + } + + if err := options.PreApplyFunc(targetRelPath, targetEntryState, lastWrittenEntryState, actualEntryState); err != nil { + return err + } + } + + if err := targetStateEntry.Apply(targetSystem, persistentState, actualStateEntry, options.Umask); err != nil { + return err + } + + return persistentStateSet(persistentState, entryStateBucket, []byte(targetAbsPath), targetEntryState) +} + +// Entries returns s's source state entries. +func (s *SourceState) Entries() map[RelPath]SourceStateEntry { + return s.entries +} + +// Entry returns the source state entry for targetRelPath. +func (s *SourceState) Entry(targetRelPath RelPath) (SourceStateEntry, bool) { + sourceStateEntry, ok := s.entries[targetRelPath] + return sourceStateEntry, ok +} + +// ExecuteTemplateData returns the result of executing template data. +func (s *SourceState) ExecuteTemplateData(name string, data []byte) ([]byte, error) { + tmpl, err := template.New(name). + Option(s.templateOptions...). + Funcs(s.templateFuncs). + Parse(string(data)) + if err != nil { + return nil, err + } + for name, t := range s.templates { + tmpl, err = tmpl.AddParseTree(name, t.Tree) + if err != nil { + return nil, err + } + } + var sb strings.Builder + if err = tmpl.ExecuteTemplate(&sb, name, s.TemplateData()); err != nil { + return nil, err + } + return []byte(sb.String()), nil +} + +// Ignored returns if targetRelPath is ignored. +func (s *SourceState) Ignored(targetRelPath RelPath) bool { + return s.ignore.match(string(targetRelPath)) +} + +// MinVersion returns the minimum version for which s is valid. +func (s *SourceState) MinVersion() semver.Version { + return s.minVersion +} + +// MustEntry returns the source state entry associated with targetRelPath, and +// panics if it does not exist. +func (s *SourceState) MustEntry(targetRelPath RelPath) SourceStateEntry { + sourceStateEntry, ok := s.entries[targetRelPath] + if !ok { + panic(fmt.Sprintf("%s: not in source state", targetRelPath)) + } + return sourceStateEntry +} + +// Read reads the source state from the source directory. +func (s *SourceState) Read() error { + info, err := s.system.Lstat(s.sourceDirAbsPath) + switch { + case os.IsNotExist(err): + return nil + case err != nil: + return err + case !info.IsDir(): + return fmt.Errorf("%s: not a directory", s.sourceDirAbsPath) + } + + // Read all source entries. + allSourceStateEntries := make(map[RelPath][]SourceStateEntry) + if err := Walk(s.system, s.sourceDirAbsPath, func(sourceAbsPath AbsPath, info os.FileInfo, err error) error { + if err != nil { + return err + } + if sourceAbsPath == s.sourceDirAbsPath { + return nil + } + sourceRelPath := SourceRelPath{ + relPath: sourceAbsPath.MustTrimDirPrefix(s.sourceDirAbsPath), + isDir: info.IsDir(), + } + + parentSourceRelPath, sourceName := sourceRelPath.Split() + // Follow symlinks in the source directory. + if info.Mode()&os.ModeType == os.ModeSymlink { + info, err = s.system.Stat(s.sourceDirAbsPath.Join(sourceRelPath.RelPath())) + if err != nil { + return err + } + } + switch { + case strings.HasPrefix(info.Name(), dataName): + return s.addTemplateData(sourceAbsPath) + case info.Name() == ignoreName: + // .chezmoiignore is interpreted as a template. we walk the + // filesystem in alphabetical order, so, luckily for us, + // .chezmoidata will be read before .chezmoiignore, so data in + // .chezmoidata is available to be used in .chezmoiignore. Unluckily + // for us, .chezmoitemplates will be read afterwards so partial + // templates will not be available in .chezmoiignore. + return s.addPatterns(s.ignore, sourceAbsPath, parentSourceRelPath) + case info.Name() == removeName: + // The comment about .chezmoiignore and templates applies to + // .chezmoiremove too. + removePatterns := newPatternSet() + if err := s.addPatterns(removePatterns, sourceAbsPath, sourceRelPath); err != nil { + return err + } + matches, err := removePatterns.glob(s.system.UnderlyingFS(), string(s.destDirAbsPath)+"/") + if err != nil { + return err + } + n := 0 + for _, match := range matches { + if !s.Ignored(RelPath(match)) { + matches[n] = match + n++ + } + } + targetParentRelPath := parentSourceRelPath.TargetRelPath() + matches = matches[:n] + for _, match := range matches { + targetRelPath := targetParentRelPath.Join(RelPath(match)) + sourceStateEntry := &SourceStateRemove{ + targetRelPath: targetRelPath, + } + allSourceStateEntries[targetRelPath] = append(allSourceStateEntries[targetRelPath], sourceStateEntry) + } + return nil + case info.Name() == templatesDirName: + if err := s.addTemplatesDir(sourceAbsPath); err != nil { + return err + } + return vfs.SkipDir + case info.Name() == versionName: + return s.addVersionFile(sourceAbsPath) + case strings.HasPrefix(info.Name(), Prefix): + fallthrough + case strings.HasPrefix(info.Name(), ignorePrefix): + if info.IsDir() { + return vfs.SkipDir + } + return nil + case info.IsDir(): + da := parseDirAttr(sourceName.String()) + targetRelPath := parentSourceRelPath.Dir().TargetRelPath().Join(RelPath(da.TargetName)) + if s.Ignored(targetRelPath) { + return vfs.SkipDir + } + sourceStateEntry := s.newSourceStateDir(sourceRelPath, da) + allSourceStateEntries[targetRelPath] = append(allSourceStateEntries[targetRelPath], sourceStateEntry) + return nil + case info.Mode().IsRegular(): + fa := parseFileAttr(sourceName.String()) + targetRelPath := parentSourceRelPath.Dir().TargetRelPath().Join(RelPath(fa.TargetName)) + if s.Ignored(targetRelPath) { + return nil + } + sourceStateEntry := s.newSourceStateFile(sourceRelPath, fa, targetRelPath) + allSourceStateEntries[targetRelPath] = append(allSourceStateEntries[targetRelPath], sourceStateEntry) + return nil + default: + return &errUnsupportedFileType{ + absPath: sourceAbsPath, + mode: info.Mode(), + } + } + }); err != nil { + return err + } + + // Remove all ignored targets. + for targetRelPath := range allSourceStateEntries { + if s.Ignored(targetRelPath) { + delete(allSourceStateEntries, targetRelPath) + } + } + + // Generate SourceStateRemoves for exact directories. + for targetRelPath, sourceStateEntries := range allSourceStateEntries { + if len(sourceStateEntries) != 1 { + continue + } + + switch sourceStateDir, ok := sourceStateEntries[0].(*SourceStateDir); { + case !ok: + continue + case !sourceStateDir.Attr.Exact: + continue + } + + switch infos, err := s.system.ReadDir(s.destDirAbsPath.Join(targetRelPath)); { + case err == nil: + for _, info := range infos { + name := info.Name() + if name == "." || name == ".." { + continue + } + destEntryRelPath := targetRelPath.Join(RelPath(name)) + if _, ok := allSourceStateEntries[destEntryRelPath]; ok { + continue + } + if s.Ignored(destEntryRelPath) { + continue + } + allSourceStateEntries[destEntryRelPath] = append(allSourceStateEntries[destEntryRelPath], &SourceStateRemove{ + targetRelPath: destEntryRelPath, + }) + } + case os.IsNotExist(err): + // Do nothing. + default: + return err + } + } + + // Check for duplicate source entries with the same target name. Iterate + // over the target names in order so that any error is deterministic. + targetRelPaths := make(RelPaths, 0, len(allSourceStateEntries)) + for targetRelPath := range allSourceStateEntries { + targetRelPaths = append(targetRelPaths, targetRelPath) + } + sort.Sort(targetRelPaths) + for _, targetRelPath := range targetRelPaths { + sourceStateEntries := allSourceStateEntries[targetRelPath] + if len(sourceStateEntries) == 1 { + continue + } + sourceRelPaths := make(SourceRelPaths, 0, len(sourceStateEntries)) + for _, sourceStateEntry := range sourceStateEntries { + sourceRelPaths = append(sourceRelPaths, sourceStateEntry.SourceRelPath()) + } + sort.Sort(sourceRelPaths) + err = multierr.Append(err, &errDuplicateTarget{ + targetRelPath: targetRelPath, + sourceRelPaths: sourceRelPaths, + }) + } + if err != nil { + return err + } + + // Populate s.Entries with the unique source entry for each target. + for targetRelPath, sourceEntries := range allSourceStateEntries { + s.entries[targetRelPath] = sourceEntries[0] + } + + return nil +} + +// TargetRelPaths returns all of s's target relative paths in order. +func (s *SourceState) TargetRelPaths() RelPaths { + targetRelPaths := make(RelPaths, 0, len(s.entries)) + for targetRelPath := range s.entries { + targetRelPaths = append(targetRelPaths, targetRelPath) + } + sort.Slice(targetRelPaths, func(i, j int) bool { + orderI := s.entries[targetRelPaths[i]].Order() + orderJ := s.entries[targetRelPaths[j]].Order() + switch { + case orderI < orderJ: + return true + case orderI == orderJ: + return targetRelPaths[i] < targetRelPaths[j] + default: + return false + } + }) + return targetRelPaths +} + +// TemplateData returns s's template data. +func (s *SourceState) TemplateData() map[string]interface{} { + if s.templateData == nil { + s.templateData = make(map[string]interface{}) + if s.defaultTemplateDataFunc != nil { + recursiveMerge(s.templateData, s.defaultTemplateDataFunc()) + s.defaultTemplateDataFunc = nil + } + recursiveMerge(s.templateData, s.userTemplateData) + recursiveMerge(s.templateData, s.priorityTemplateData) + } + return s.templateData +} + +// addPatterns executes the template at sourceAbsPath, interprets the result as +// a list of patterns, and adds all patterns found to patternSet. +func (s *SourceState) addPatterns(patternSet *patternSet, sourceAbsPath AbsPath, sourceRelPath SourceRelPath) error { + data, err := s.executeTemplate(sourceAbsPath) + if err != nil { + return err + } + dir := sourceRelPath.Dir().TargetRelPath() + scanner := bufio.NewScanner(bytes.NewReader(data)) + var lineNumber int + for scanner.Scan() { + lineNumber++ + text := scanner.Text() + if index := strings.IndexRune(text, '#'); index != -1 { + text = text[:index] + } + text = strings.TrimSpace(text) + if text == "" { + continue + } + include := true + if strings.HasPrefix(text, "!") { + include = false + text = mustTrimPrefix(text, "!") + } + pattern := string(dir.Join(RelPath(text))) + if err := patternSet.add(pattern, include); err != nil { + return fmt.Errorf("%s:%d: %w", sourceAbsPath, lineNumber, err) + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("%s: %w", sourceAbsPath, err) + } + return nil +} + +// addTemplateData adds all template data in sourceAbsPath to s. +func (s *SourceState) addTemplateData(sourceAbsPath AbsPath) error { + _, name := sourceAbsPath.Split() + suffix := mustTrimPrefix(string(name), dataName+".") + format, ok := Formats[suffix] + if !ok { + return fmt.Errorf("%s: unknown format", sourceAbsPath) + } + data, err := s.system.ReadFile(sourceAbsPath) + if err != nil { + return fmt.Errorf("%s: %w", sourceAbsPath, err) + } + var templateData map[string]interface{} + if err := format.Unmarshal(data, &templateData); err != nil { + return fmt.Errorf("%s: %w", sourceAbsPath, err) + } + recursiveMerge(s.userTemplateData, templateData) + return nil +} + +// addTemplatesDir adds all templates in templateDir to s. +func (s *SourceState) addTemplatesDir(templatesDirAbsPath AbsPath) error { + return Walk(s.system, templatesDirAbsPath, func(templateAbsPath AbsPath, info os.FileInfo, err error) error { + if err != nil { + return err + } + switch { + case info.Mode().IsRegular(): + contents, err := s.system.ReadFile(templateAbsPath) + if err != nil { + return err + } + templateRelPath := templateAbsPath.MustTrimDirPrefix(templatesDirAbsPath) + name := string(templateRelPath) + tmpl, err := template.New(name).Option(s.templateOptions...).Funcs(s.templateFuncs).Parse(string(contents)) + if err != nil { + return err + } + if s.templates == nil { + s.templates = make(map[string]*template.Template) + } + s.templates[name] = tmpl + return nil + case info.IsDir(): + return nil + default: + return &errUnsupportedFileType{ + absPath: templateAbsPath, + mode: info.Mode(), + } + } + }) +} + +// addVersionFile reads a .chezmoiversion file from source path and updates s's +// minimum version if it contains a more recent version than the current minimum +// version. +func (s *SourceState) addVersionFile(sourceAbsPath AbsPath) error { + data, err := s.system.ReadFile(sourceAbsPath) + if err != nil { + return err + } + version, err := semver.NewVersion(strings.TrimSpace(string(data))) + if err != nil { + return err + } + if s.minVersion.LessThan(*version) { + s.minVersion = *version + } + return nil +} + +// applyAll updates targetDir in fs to match s. +func (s *SourceState) applyAll(targetSystem System, persistentState PersistentState, targetDir AbsPath, options ApplyOptions) error { + for _, targetRelPath := range s.TargetRelPaths() { + switch err := s.Apply(targetSystem, persistentState, targetDir, targetRelPath, options); { + case errors.Is(err, Skip): + continue + case err != nil: + return err + } + } + return nil +} + +// executeTemplate executes the template at path and returns the result. +func (s *SourceState) executeTemplate(templateAbsPath AbsPath) ([]byte, error) { + data, err := s.system.ReadFile(templateAbsPath) + if err != nil { + return nil, err + } + return s.ExecuteTemplateData(string(templateAbsPath), data) +} + +// newSourceStateDir returns a new SourceStateDir. +func (s *SourceState) newSourceStateDir(sourceRelPath SourceRelPath, dirAttr DirAttr) *SourceStateDir { + targetStateDir := &TargetStateDir{ + perm: dirAttr.perm(), + } + return &SourceStateDir{ + sourceRelPath: sourceRelPath, + Attr: dirAttr, + targetStateEntry: targetStateDir, + } +} + +// newSourceStateFile returns a new SourceStateFile. +func (s *SourceState) newSourceStateFile(sourceRelPath SourceRelPath, fileAttr FileAttr, targetRelPath RelPath) *SourceStateFile { + lazyContents := &lazyContents{ + contentsFunc: func() ([]byte, error) { + contents, err := s.system.ReadFile(s.sourceDirAbsPath.Join(sourceRelPath.RelPath())) + if err != nil { + return nil, err + } + if fileAttr.Encrypted { + contents, err = s.encryption.Decrypt(contents) + if err != nil { + return nil, err + } + } + return contents, nil + }, + } + + var targetStateEntryFunc func() (TargetStateEntry, error) + switch fileAttr.Type { + case SourceFileTypeFile: + targetStateEntryFunc = func() (TargetStateEntry, error) { + contents, err := lazyContents.Contents() + if err != nil { + return nil, err + } + if fileAttr.Template { + contents, err = s.ExecuteTemplateData(sourceRelPath.String(), contents) + if err != nil { + return nil, err + } + } + if !fileAttr.Empty && isEmpty(contents) { + return &TargetStateAbsent{}, nil + } + return &TargetStateFile{ + lazyContents: newLazyContents(contents), + perm: fileAttr.perm(), + }, nil + } + case SourceFileTypePresent: + targetStateEntryFunc = func() (TargetStateEntry, error) { + contents, err := lazyContents.Contents() + if err != nil { + return nil, err + } + if fileAttr.Template { + contents, err = s.ExecuteTemplateData(sourceRelPath.String(), contents) + if err != nil { + return nil, err + } + } + return &TargetStatePresent{ + lazyContents: newLazyContents(contents), + perm: fileAttr.perm(), + }, nil + } + case SourceFileTypeScript: + targetStateEntryFunc = func() (TargetStateEntry, error) { + contents, err := lazyContents.Contents() + if err != nil { + return nil, err + } + if fileAttr.Template { + contents, err = s.ExecuteTemplateData(sourceRelPath.String(), contents) + if err != nil { + return nil, err + } + } + return &TargetStateScript{ + lazyContents: newLazyContents(contents), + name: targetRelPath, + once: fileAttr.Once, + }, nil + } + case SourceFileTypeSymlink: + targetStateEntryFunc = func() (TargetStateEntry, error) { + linknameBytes, err := lazyContents.Contents() + if err != nil { + return nil, err + } + if fileAttr.Template { + linknameBytes, err = s.ExecuteTemplateData(sourceRelPath.String(), linknameBytes) + if err != nil { + return nil, err + } + } + return &TargetStateSymlink{ + lazyLinkname: newLazyLinkname(string(bytes.TrimSpace(linknameBytes))), + }, nil + } + default: + panic(fmt.Sprintf("%d: unsupported type", fileAttr.Type)) + } + + return &SourceStateFile{ + lazyContents: lazyContents, + sourceRelPath: sourceRelPath, + Attr: fileAttr, + targetStateEntryFunc: targetStateEntryFunc, + } +} + +// sourceStateEntry returns a new SourceStateEntry based on actualStateEntry. +func (s *SourceState) sourceStateEntry(actualStateEntry ActualStateEntry, destAbsPath AbsPath, info os.FileInfo, parentSourceRelPath SourceRelPath, options *AddOptions) (SourceStateEntry, error) { + switch actualStateEntry := actualStateEntry.(type) { + case *ActualStateAbsent: + return nil, fmt.Errorf("%s: not found", destAbsPath) + case *ActualStateDir: + dirAttr := DirAttr{ + TargetName: info.Name(), + Exact: options.Exact, + Private: isPrivate(info), + } + return &SourceStateDir{ + Attr: dirAttr, + sourceRelPath: parentSourceRelPath.Join(NewSourceRelDirPath(RelPath(dirAttr.SourceName()))), + targetStateEntry: &TargetStateDir{ + perm: 0o777, + }, + }, nil + case *ActualStateFile: + fileAttr := FileAttr{ + TargetName: info.Name(), + Empty: options.Empty, + Encrypted: options.Encrypt, + Executable: isExecutable(info), + Private: isPrivate(info), + Template: options.Template || options.AutoTemplate, + } + if options.Exists { + fileAttr.Type = SourceFileTypePresent + } else { + fileAttr.Type = SourceFileTypeFile + } + contents, err := actualStateEntry.Contents() + if err != nil { + return nil, err + } + if options.AutoTemplate { + contents = autoTemplate(contents, s.TemplateData()) + } + if len(contents) == 0 && !options.Empty { + return nil, nil + } + if options.Encrypt { + contents, err = s.encryption.Encrypt(contents) + if err != nil { + return nil, err + } + } + lazyContents := &lazyContents{ + contents: contents, + } + return &SourceStateFile{ + Attr: fileAttr, + sourceRelPath: parentSourceRelPath.Join(NewSourceRelPath(RelPath(fileAttr.SourceName()))), + lazyContents: lazyContents, + targetStateEntry: &TargetStateFile{ + lazyContents: lazyContents, + perm: 0o666, + }, + }, nil + case *ActualStateSymlink: + fileAttr := FileAttr{ + TargetName: info.Name(), + Type: SourceFileTypeSymlink, + Template: options.Template || options.AutoTemplate, + } + linkname, err := actualStateEntry.Linkname() + if err != nil { + return nil, err + } + contents := []byte(linkname) + if options.AutoTemplate { + contents = autoTemplate(contents, s.TemplateData()) + } + contents = append(contents, '\n') + lazyContents := &lazyContents{ + contents: contents, + } + return &SourceStateFile{ + Attr: fileAttr, + sourceRelPath: parentSourceRelPath.Join(NewSourceRelPath(RelPath(fileAttr.SourceName()))), + lazyContents: lazyContents, + targetStateEntry: &TargetStateFile{ + lazyContents: lazyContents, + perm: 0o666, + }, + }, nil + default: + panic(fmt.Sprintf("%T: unsupported type", actualStateEntry)) + } +} diff --git a/chezmoi2/internal/chezmoi/sourcestate_test.go b/chezmoi2/internal/chezmoi/sourcestate_test.go new file mode 100644 index 000000000000..a8b0300451d7 --- /dev/null +++ b/chezmoi2/internal/chezmoi/sourcestate_test.go @@ -0,0 +1,1283 @@ +package chezmoi + +import ( + "os" + "path/filepath" + "testing" + "text/template" + + "github.com/coreos/go-semver/semver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestSourceStateAdd(t *testing.T) { + for _, tc := range []struct { + name string + destAbsPaths AbsPaths + addOptions AddOptions + extraRoot interface{} + tests []interface{} + }{ + { + name: "dir", + destAbsPaths: AbsPaths{ + "/home/user/.dir", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "dir_change_attributes", + destAbsPaths: AbsPaths{ + "/home/user/.dir", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + extraRoot: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi/exact_dot_dir/file": "# contents of file\n", + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/exact_dot_dir", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of file\n"), + ), + }, + }, + { + name: "dir_file", + destAbsPaths: AbsPaths{ + "/home/user/.dir/file", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .dir/file\n"), + ), + }, + }, + { + name: "dir_file_existing_dir", + destAbsPaths: AbsPaths{ + "/home/user/.dir/file", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + extraRoot: map[string]interface{}{ + "/home/user/.local/share/chezmoi/dot_dir": &vfst.Dir{Perm: 0o777}, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", + vfst.TestModeIsRegular, + vfst.TestContentsString("# contents of .dir/file\n"), + ), + }, + }, + { + name: "dir_subdir", + destAbsPaths: AbsPaths{ + "/home/user/.dir/subdir", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir/file", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "dir_subdir_file", + destAbsPaths: AbsPaths{ + "/home/user/.dir/subdir/file", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir/file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .dir/subdir/file\n"), + ), + }, + }, + { + name: "dir_subdir_file_existing_dir_subdir", + destAbsPaths: AbsPaths{ + "/home/user/.dir/subdir/file", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + extraRoot: map[string]interface{}{ + "/home/user/.local/share/chezmoi/dot_dir/subdir": &vfst.Dir{Perm: 0o777}, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir/file", + vfst.TestModeIsRegular, + vfst.TestContentsString("# contents of .dir/subdir/file\n"), + ), + }, + }, + { + name: "empty", + destAbsPaths: AbsPaths{ + "/home/user/.empty", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_empty", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "empty_with_empty", + destAbsPaths: AbsPaths{ + "/home/user/.empty", + }, + addOptions: AddOptions{ + Empty: true, + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/empty_dot_empty", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContents(nil), + ), + }, + }, + { + name: "executable_unix", + destAbsPaths: AbsPaths{ + "/home/user/.executable", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/executable_dot_executable", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .executable\n"), + ), + }, + }, + { + name: "executable_windows", + destAbsPaths: AbsPaths{ + "/home/user/.executable", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_executable", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .executable\n"), + ), + }, + }, + { + name: "exists", + destAbsPaths: AbsPaths{ + "/home/user/.exists", + }, + addOptions: AddOptions{ + Exists: true, + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/exists_dot_exists", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .exists\n"), + ), + }, + }, + { + name: "file", + destAbsPaths: AbsPaths{ + "/home/user/.file", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .file\n"), + ), + }, + }, + { + name: "file_change_attributes", + destAbsPaths: AbsPaths{ + "/home/user/.file", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + extraRoot: map[string]interface{}{ + "/home/user/.local/share/chezmoi/executable_dot_file": "# contents of .file\n", + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .file\n"), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/executable_dot_file", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "file_replace_contents", + destAbsPaths: AbsPaths{ + "/home/user/.file", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + extraRoot: map[string]interface{}{ + "/home/user/.local/share/chezmoi/dot_file": "# old contents of .file\n", + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .file\n"), + ), + }, + }, + { + name: "private_unix", + destAbsPaths: AbsPaths{ + "/home/user/.private", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_private", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .private\n"), + ), + }, + }, + { + name: "private_windows", + destAbsPaths: AbsPaths{ + "/home/user/.private", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_private", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .private\n"), + ), + }, + }, + { + name: "symlink", + destAbsPaths: AbsPaths{ + "/home/user/.symlink", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/symlink_dot_symlink", + vfst.TestModeIsRegular, + vfst.TestContentsString(".dir/subdir/file\n"), + ), + }, + }, + { + name: "symlink_backslash_windows", + destAbsPaths: AbsPaths{ + "/home/user/.symlink_windows", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + extraRoot: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".symlink_windows": &vfst.Symlink{Target: ".dir\\subdir\\file"}, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/symlink_dot_symlink_windows", + vfst.TestModeIsRegular, + vfst.TestContentsString(".dir/subdir/file\n"), + ), + }, + }, + { + name: "template", + destAbsPaths: AbsPaths{ + "/home/user/.template", + }, + addOptions: AddOptions{ + AutoTemplate: true, + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_template.tmpl", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("key = {{ .variable }}\n"), + ), + }, + }, + { + name: "dir_and_dir_file", + destAbsPaths: AbsPaths{ + "/home/user/.dir", + "/home/user/.dir/file", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^GetUmask()), + ), + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .dir/file\n"), + ), + }, + }, + { + name: "file_in_dir_exact_subdir", + destAbsPaths: AbsPaths{ + "/home/user/.dir/subdir/file", + }, + addOptions: AddOptions{ + Include: NewIncludeSet(IncludeAll), + }, + extraRoot: map[string]interface{}{ + "/home/user/.local/share/chezmoi/dot_dir/exact_subdir": &vfst.Dir{Perm: 0o777}, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/exact_subdir/file", + vfst.TestModeIsRegular, + vfst.TestContentsString("# contents of .dir/subdir/file\n"), + ), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.SkipUnlessGOOS(t, tc.name) + + chezmoitest.WithTestFS(t, map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".dir": map[string]interface{}{ + "file": "# contents of .dir/file\n", + "subdir": map[string]interface{}{ + "file": "# contents of .dir/subdir/file\n", + }, + }, + ".empty": "", + ".executable": &vfst.File{ + Perm: 0o777, + Contents: []byte("# contents of .executable\n"), + }, + ".exists": "# contents of .exists\n", + ".file": "# contents of .file\n", + ".local": map[string]interface{}{ + "share": map[string]interface{}{ + "chezmoi": &vfst.Dir{Perm: 0o777}, + }, + }, + ".private": &vfst.File{ + Perm: 0o600, + Contents: []byte("# contents of .private\n"), + }, + ".symlink": &vfst.Symlink{Target: ".dir/subdir/file"}, + ".template": "key = value\n", + }, + }, func(fs vfs.FS) { + system := NewRealSystem(fs) + persistentState := NewMockPersistentState() + if tc.extraRoot != nil { + require.NoError(t, vfst.NewBuilder().Build(system.UnderlyingFS(), tc.extraRoot)) + } + + sourceState := NewSourceState( + WithDestDir("/home/user"), + WithSourceDir("/home/user/.local/share/chezmoi"), + WithSystem(system), + withUserTemplateData(map[string]interface{}{ + "variable": "value", + }), + ) + require.NoError(t, sourceState.Read()) + require.NoError(t, sourceState.evaluateAll()) + + destAbsPathInfos := make(map[AbsPath]os.FileInfo) + for _, destAbsPath := range tc.destAbsPaths { + require.NoError(t, sourceState.AddDestAbsPathInfos(destAbsPathInfos, system, destAbsPath, nil)) + } + require.NoError(t, sourceState.Add(system, persistentState, system, destAbsPathInfos, &tc.addOptions)) + + vfst.RunTests(t, fs, "", tc.tests...) + }) + }) + } +} + +func TestSourceStateApplyAll(t *testing.T) { + // FIXME script tests + // FIXME script template tests + + for _, tc := range []struct { + name string + root interface{} + sourceStateOptions []SourceStateOption + tests []interface{} + }{ + { + name: "empty", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": &vfst.Dir{Perm: 0o777}, + }, + }, + }, + { + name: "dir", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "dot_dir": &vfst.Dir{Perm: 0o777}, + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^GetUmask()), + ), + }, + }, + { + name: "dir_exact", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".dir": map[string]interface{}{ + "file": "# contents of .dir/file\n", + }, + ".local/share/chezmoi": map[string]interface{}{ + "exact_dot_dir": &vfst.Dir{Perm: 0o777}, + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.dir", + vfst.TestIsDir, + vfst.TestModePerm(0o777&^GetUmask()), + ), + vfst.TestPath("/home/user/.dir/file", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "file", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "dot_file": "# contents of .file\n", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.file", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .file\n"), + ), + }, + }, + { + name: "file_remove_empty", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".empty": "# contents of .empty\n", + ".local/share/chezmoi": map[string]interface{}{ + "dot_empty": "", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.empty", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "file_create_empty", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "empty_dot_empty": "", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.empty", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContents(nil), + ), + }, + }, + { + name: "file_template", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "dot_template.tmpl": "key = {{ .variable }}\n", + }, + }, + }, + sourceStateOptions: []SourceStateOption{ + withUserTemplateData(map[string]interface{}{ + "variable": "value", + }), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.template", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("key = value\n"), + ), + }, + }, + { + name: "exists_create", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "exists_dot_exists": "# contents of .exists\n", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.exists", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# contents of .exists\n"), + ), + }, + }, + { + name: "exists_no_replace", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "exists_dot_exists": "# contents of .exists\n", + }, + ".exists": "# existing contents of .exists\n", + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.exists", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o666&^GetUmask()), + vfst.TestContentsString("# existing contents of .exists\n"), + ), + }, + }, + { + name: "symlink", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "symlink_dot_symlink": ".dir/subdir/file\n", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.symlink", + vfst.TestModeType(os.ModeSymlink), + vfst.TestSymlinkTarget(filepath.FromSlash(".dir/subdir/file")), + ), + }, + }, + { + name: "symlink_template", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "symlink_dot_symlink.tmpl": `{{ ".dir/subdir/file" }}` + "\n", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/.symlink", + vfst.TestModeType(os.ModeSymlink), + vfst.TestSymlinkTarget(filepath.FromSlash(".dir/subdir/file")), + ), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fs vfs.FS) { + system := NewRealSystem(fs) + persistentState := NewMockPersistentState() + sourceStateOptions := []SourceStateOption{ + WithDestDir("/home/user"), + WithSourceDir("/home/user/.local/share/chezmoi"), + WithSystem(system), + } + sourceStateOptions = append(sourceStateOptions, tc.sourceStateOptions...) + sourceState := NewSourceState(sourceStateOptions...) + require.NoError(t, sourceState.Read()) + require.NoError(t, sourceState.evaluateAll()) + require.NoError(t, sourceState.applyAll(system, persistentState, "/home/user", ApplyOptions{ + Umask: GetUmask(), + })) + + vfst.RunTests(t, fs, "", tc.tests...) + }) + }) + } +} + +func TestSourceStateRead(t *testing.T) { + for _, tc := range []struct { + name string + root interface{} + expectedError string + expectedSourceState *SourceState + }{ + { + name: "empty", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": &vfst.Dir{Perm: 0o777}, + }, + expectedSourceState: NewSourceState(), + }, + { + name: "dir", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "dir": &vfst.Dir{Perm: 0o777}, + }, + }, + expectedSourceState: NewSourceState( + withEntries(map[RelPath]SourceStateEntry{ + "dir": &SourceStateDir{ + sourceRelPath: NewSourceRelDirPath("dir"), + Attr: DirAttr{ + TargetName: "dir", + }, + targetStateEntry: &TargetStateDir{ + perm: 0o777, + }, + }, + }), + ), + }, + { + name: "file", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "dot_file": "# contents of .file\n", + }, + }, + expectedSourceState: NewSourceState( + withEntries(map[RelPath]SourceStateEntry{ + ".file": &SourceStateFile{ + sourceRelPath: NewSourceRelPath("dot_file"), + Attr: FileAttr{ + TargetName: ".file", + Type: SourceFileTypeFile, + }, + lazyContents: newLazyContents([]byte("# contents of .file\n")), + targetStateEntry: &TargetStateFile{ + perm: 0o666, + lazyContents: newLazyContents([]byte("# contents of .file\n")), + }, + }, + }), + ), + }, + { + name: "duplicate_target_file", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "dot_file": "# contents of .file\n", + "dot_file.tmpl": "# contents of .file\n", + }, + }, + expectedError: ".file: duplicate source state entries (dot_file, dot_file.tmpl)", + }, + { + name: "duplicate_target_dir", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "dir": &vfst.Dir{Perm: 0o777}, + "exact_dir": &vfst.Dir{Perm: 0o777}, + }, + }, + expectedError: "dir: duplicate source state entries (dir, exact_dir)", + }, + { + name: "duplicate_target_script", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "run_script": "#!/bin/sh\n", + "run_once_script": "#!/bin/sh\n", + }, + }, + expectedError: "script: duplicate source state entries (run_once_script, run_script)", + }, + { + name: "symlink_with_attr", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".file": "# contents of .file\n", + "executable_dot_file": &vfst.Symlink{Target: ".file"}, + }, + }, + expectedSourceState: NewSourceState( + withEntries(map[RelPath]SourceStateEntry{ + ".file": &SourceStateFile{ + sourceRelPath: NewSourceRelPath("executable_dot_file"), + Attr: FileAttr{ + TargetName: ".file", + Type: SourceFileTypeFile, + Executable: true, + }, + lazyContents: newLazyContents([]byte("# contents of .file\n")), + targetStateEntry: &TargetStateFile{ + perm: 0o777, + lazyContents: newLazyContents([]byte("# contents of .file\n")), + }, + }, + }), + ), + }, + { + name: "symlink_script", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".script": "# contents of .script\n", + "run_script": &vfst.Symlink{Target: ".script"}, + }, + }, + expectedSourceState: NewSourceState( + withEntries(map[RelPath]SourceStateEntry{ + "script": &SourceStateFile{ + sourceRelPath: NewSourceRelPath("run_script"), + Attr: FileAttr{ + TargetName: "script", + Type: SourceFileTypeScript, + }, + lazyContents: newLazyContents([]byte("# contents of .script\n")), + targetStateEntry: &TargetStateScript{ + name: "script", + lazyContents: newLazyContents([]byte("# contents of .script\n")), + }, + }, + }), + ), + }, + { + name: "script", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "run_script": "# contents of script\n", + }, + }, + expectedSourceState: NewSourceState( + withEntries(map[RelPath]SourceStateEntry{ + "script": &SourceStateFile{ + sourceRelPath: NewSourceRelPath("run_script"), + Attr: FileAttr{ + TargetName: "script", + Type: SourceFileTypeScript, + }, + lazyContents: newLazyContents([]byte("# contents of script\n")), + targetStateEntry: &TargetStateScript{ + name: "script", + lazyContents: newLazyContents([]byte("# contents of script\n")), + }, + }, + }), + ), + }, + { + name: "symlink", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "symlink_dot_symlink": ".dir/subdir/file", + }, + }, + expectedSourceState: NewSourceState( + withEntries(map[RelPath]SourceStateEntry{ + ".symlink": &SourceStateFile{ + sourceRelPath: NewSourceRelPath("symlink_dot_symlink"), + Attr: FileAttr{ + TargetName: ".symlink", + Type: SourceFileTypeSymlink, + }, + lazyContents: newLazyContents([]byte(".dir/subdir/file")), + targetStateEntry: &TargetStateSymlink{ + lazyLinkname: newLazyLinkname(".dir/subdir/file"), + }, + }, + }), + ), + }, + { + name: "file_in_dir", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "dir": map[string]interface{}{ + "file": "# contents of .dir/file\n", + }, + }, + }, + expectedSourceState: NewSourceState( + withEntries(map[RelPath]SourceStateEntry{ + "dir": &SourceStateDir{ + sourceRelPath: NewSourceRelDirPath("dir"), + Attr: DirAttr{ + TargetName: "dir", + }, + targetStateEntry: &TargetStateDir{ + perm: 0o777, + }, + }, + "dir/file": &SourceStateFile{ + sourceRelPath: NewSourceRelPath("dir/file"), + Attr: FileAttr{ + TargetName: "file", + Type: SourceFileTypeFile, + }, + lazyContents: &lazyContents{ + contents: []byte("# contents of .dir/file\n"), + }, + targetStateEntry: &TargetStateFile{ + perm: 0o666, + lazyContents: &lazyContents{ + contents: []byte("# contents of .dir/file\n"), + }, + }, + }, + }), + ), + }, + { + name: "chezmoiignore", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + }, + }, + expectedSourceState: NewSourceState( + withIgnore( + mustNewPatternSet(t, map[string]bool{ + "README.md": true, + }), + ), + ), + }, + { + name: "chezmoiignore_ignore_file", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + "README.md": "", + }, + }, + expectedSourceState: NewSourceState( + withIgnore( + mustNewPatternSet(t, map[string]bool{ + "README.md": true, + }), + ), + ), + }, + { + name: "chezmoiignore_exact_dir", + root: map[string]interface{}{ + "/home/user/dir": map[string]interface{}{ + "file1": "# contents of dir/file1\n", + "file2": "# contents of dir/file2\n", + "file3": "# contents of dir/file3\n", + }, + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "dir/file3\n", + "exact_dir": map[string]interface{}{ + "file1": "# contents of dir/file1\n", + }, + }, + }, + expectedSourceState: NewSourceState( + withEntries(map[RelPath]SourceStateEntry{ + "dir": &SourceStateDir{ + sourceRelPath: NewSourceRelDirPath("exact_dir"), + Attr: DirAttr{ + TargetName: "dir", + Exact: true, + }, + targetStateEntry: &TargetStateDir{ + perm: 0o777, + }, + }, + "dir/file1": &SourceStateFile{ + sourceRelPath: NewSourceRelPath("exact_dir/file1"), + Attr: FileAttr{ + TargetName: "file1", + Type: SourceFileTypeFile, + }, + lazyContents: &lazyContents{ + contents: []byte("# contents of dir/file1\n"), + }, + targetStateEntry: &TargetStateFile{ + perm: 0o666, + lazyContents: &lazyContents{ + contents: []byte("# contents of dir/file1\n"), + }, + }, + }, + "dir/file2": &SourceStateRemove{ + targetRelPath: "dir/file2", + }, + }), + withIgnore( + mustNewPatternSet(t, map[string]bool{ + "dir/file3": true, + }), + ), + ), + }, + { + name: "chezmoiremove", + root: map[string]interface{}{ + "/home/user/file": "", + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiremove": "file\n", + }, + }, + expectedSourceState: NewSourceState( + withEntries(map[RelPath]SourceStateEntry{ + "file": &SourceStateRemove{ + targetRelPath: "file", + }, + }), + ), + }, + { + name: "chezmoiremove_and_ignore", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "file1": "", + "file2": "", + }, + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "file2\n", + ".chezmoiremove": "file*\n", + }, + }, + expectedSourceState: NewSourceState( + withEntries(map[RelPath]SourceStateEntry{ + "file1": &SourceStateRemove{ + targetRelPath: "file1", + }, + }), + withIgnore( + mustNewPatternSet(t, map[string]bool{ + "file2": true, + }), + ), + ), + }, + { + name: "chezmoitemplates", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoitemplates": map[string]interface{}{ + "template": "# contents of .chezmoitemplates/template\n", + }, + }, + }, + expectedSourceState: NewSourceState( + withTemplates( + map[string]*template.Template{ + "template": template.Must(template.New("template").Option("missingkey=error").Parse("# contents of .chezmoitemplates/template\n")), + }, + ), + ), + }, + { + name: "chezmoiversion", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiversion": "1.2.3\n", + }, + }, + expectedSourceState: NewSourceState( + withMinVersion( + semver.Version{ + Major: 1, + Minor: 2, + Patch: 3, + }, + ), + ), + }, + { + name: "chezmoiversion_multiple", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiversion": "1.2.3\n", + "dir": map[string]interface{}{ + ".chezmoiversion": "2.3.4\n", + }, + }, + }, + expectedSourceState: NewSourceState( + withEntries(map[RelPath]SourceStateEntry{ + "dir": &SourceStateDir{ + sourceRelPath: NewSourceRelDirPath("dir"), + Attr: DirAttr{ + TargetName: "dir", + }, + targetStateEntry: &TargetStateDir{ + perm: 0o777, + }, + }, + }), + withMinVersion( + semver.Version{ + Major: 2, + Minor: 3, + Patch: 4, + }, + ), + ), + }, + { + name: "ignore_dir", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".dir": map[string]interface{}{ + "file": "# contents of .dir/file\n", + }, + }, + }, + expectedSourceState: NewSourceState(), + }, + { + name: "ignore_file", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".file": "# contents of .file\n", + }, + }, + expectedSourceState: NewSourceState(), + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fs vfs.FS) { + s := NewSourceState( + WithDestDir("/home/user"), + WithSourceDir("/home/user/.local/share/chezmoi"), + WithSystem(NewRealSystem(fs)), + ) + err := s.Read() + if tc.expectedError != "" { + assert.Error(t, err) + assert.Equal(t, tc.expectedError, err.Error()) + return + } + require.NoError(t, err) + require.NoError(t, s.evaluateAll()) + tc.expectedSourceState.destDirAbsPath = "/home/user" + tc.expectedSourceState.sourceDirAbsPath = "/home/user/.local/share/chezmoi" + require.NoError(t, tc.expectedSourceState.evaluateAll()) + s.system = nil + s.templateData = nil + assert.Equal(t, tc.expectedSourceState, s) + }) + }) + } +} + +func TestSourceStateTargetRelPaths(t *testing.T) { + for _, tc := range []struct { + name string + root interface{} + expectedTargetRelPaths RelPaths + }{ + { + name: "empty", + root: nil, + expectedTargetRelPaths: RelPaths{}, + }, + { + name: "scripts", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "run_first_1first": "", + "run_first_2first": "", + "run_first_3first": "", + "run_1": "", + "run_2": "", + "run_3": "", + "run_last_1last": "", + "run_last_2last": "", + "run_last_3last": "", + }, + }, + expectedTargetRelPaths: RelPaths{ + "1first", + "2first", + "3first", + "1", + "2", + "3", + "1last", + "2last", + "3last", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fs vfs.FS) { + s := NewSourceState( + WithSourceDir("/home/user/.local/share/chezmoi"), + WithSystem(NewRealSystem(fs)), + ) + require.NoError(t, s.Read()) + assert.Equal(t, tc.expectedTargetRelPaths, s.TargetRelPaths()) + }) + }) + } +} + +// evaluateAll evaluates every target state entry in s. +func (s *SourceState) evaluateAll() error { + for _, targetRelPath := range s.TargetRelPaths() { + sourceStateEntry := s.entries[targetRelPath] + if err := sourceStateEntry.Evaluate(); err != nil { + return err + } + targetStateEntry, err := sourceStateEntry.TargetStateEntry() + if err != nil { + return err + } + if err := targetStateEntry.Evaluate(); err != nil { + return err + } + } + return nil +} + +func withEntries(sourceEntries map[RelPath]SourceStateEntry) SourceStateOption { + return func(s *SourceState) { + s.entries = sourceEntries + } +} + +func withIgnore(ignore *patternSet) SourceStateOption { + return func(s *SourceState) { + s.ignore = ignore + } +} + +func withMinVersion(minVersion semver.Version) SourceStateOption { + return func(s *SourceState) { + s.minVersion = minVersion + } +} + +// withUserTemplateData adds template data. +func withUserTemplateData(templateData map[string]interface{}) SourceStateOption { + return func(s *SourceState) { + recursiveMerge(s.userTemplateData, templateData) + } +} + +func withTemplates(templates map[string]*template.Template) SourceStateOption { + return func(s *SourceState) { + s.templates = templates + } +} diff --git a/chezmoi2/internal/chezmoi/sourcestateentry.go b/chezmoi2/internal/chezmoi/sourcestateentry.go new file mode 100644 index 000000000000..98aea16460bb --- /dev/null +++ b/chezmoi2/internal/chezmoi/sourcestateentry.go @@ -0,0 +1,126 @@ +package chezmoi + +// A SourceStateEntry represents the state of an entry in the source state. +type SourceStateEntry interface { + Evaluate() error + Order() int + SourceRelPath() SourceRelPath + TargetStateEntry() (TargetStateEntry, error) +} + +// A SourceStateDir represents the state of a directory in the source state. +type SourceStateDir struct { + Attr DirAttr + sourceRelPath SourceRelPath + targetStateEntry TargetStateEntry +} + +// A SourceStateFile represents the state of a file in the source state. +type SourceStateFile struct { + *lazyContents + Attr FileAttr + sourceRelPath SourceRelPath + targetStateEntryFunc func() (TargetStateEntry, error) + targetStateEntry TargetStateEntry + targetStateEntryErr error +} + +// A SourceStateRemove represents that an entry should be removed. +type SourceStateRemove struct { + targetRelPath RelPath +} + +// A SourceStateRenameDir represents the renaming of a directory in the source +// state. +type SourceStateRenameDir struct { + oldSourceRelPath SourceRelPath + newSourceRelPath SourceRelPath +} + +// Evaluate evaluates s and returns any error. +func (s *SourceStateDir) Evaluate() error { + return nil +} + +// Order returns s's order. +func (s *SourceStateDir) Order() int { + return 0 +} + +// SourceRelPath returns s's source relative path. +func (s *SourceStateDir) SourceRelPath() SourceRelPath { + return s.sourceRelPath +} + +// TargetStateEntry returns s's target state entry. +func (s *SourceStateDir) TargetStateEntry() (TargetStateEntry, error) { + return s.targetStateEntry, nil +} + +// Evaluate evaluates s and returns any error. +func (s *SourceStateFile) Evaluate() error { + _, err := s.ContentsSHA256() + return err +} + +// Order returns s's order. +func (s *SourceStateFile) Order() int { + return s.Attr.Order +} + +// SourceRelPath returns s's source relative path. +func (s *SourceStateFile) SourceRelPath() SourceRelPath { + return s.sourceRelPath +} + +// TargetStateEntry returns s's target state entry. +func (s *SourceStateFile) TargetStateEntry() (TargetStateEntry, error) { + if s.targetStateEntryFunc != nil { + s.targetStateEntry, s.targetStateEntryErr = s.targetStateEntryFunc() + s.targetStateEntryFunc = nil + } + return s.targetStateEntry, s.targetStateEntryErr +} + +// Evaluate evaluates s and returns any error. +func (s *SourceStateRemove) Evaluate() error { + return nil +} + +// Order returns s's order. +func (s *SourceStateRemove) Order() int { + return 0 +} + +// SourceRelPath returns s's source relative path. +func (s *SourceStateRemove) SourceRelPath() SourceRelPath { + return SourceRelPath{} +} + +// TargetStateEntry returns s's target state entry. +func (s *SourceStateRemove) TargetStateEntry() (TargetStateEntry, error) { + return &TargetStateAbsent{}, nil +} + +// Evaluate evaluates s and returns any error. +func (s *SourceStateRenameDir) Evaluate() error { + return nil +} + +// Order returns s's order. +func (s *SourceStateRenameDir) Order() int { + return -1 +} + +// SourceRelPath returns s's source relative path. +func (s *SourceStateRenameDir) SourceRelPath() SourceRelPath { + return s.newSourceRelPath +} + +// TargetStateEntry returns s's target state entry. +func (s *SourceStateRenameDir) TargetStateEntry() (TargetStateEntry, error) { + return &TargetStateRenameDir{ + oldRelPath: s.oldSourceRelPath.RelPath(), + newRelPath: s.newSourceRelPath.RelPath(), + }, nil +} diff --git a/chezmoi2/internal/chezmoi/system.go b/chezmoi2/internal/chezmoi/system.go new file mode 100644 index 000000000000..8287337a1152 --- /dev/null +++ b/chezmoi2/internal/chezmoi/system.go @@ -0,0 +1,108 @@ +package chezmoi + +import ( + "os" + "os/exec" + "path/filepath" + + vfs "github.com/twpayne/go-vfs" +) + +// A System reads from and writes to a filesystem, executes idempotent commands, +// runs scripts, and persists state. +type System interface { + Chmod(name AbsPath, mode os.FileMode) error + Glob(pattern string) ([]string, error) + IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) + Lstat(filename AbsPath) (os.FileInfo, error) + Mkdir(name AbsPath, perm os.FileMode) error + RawPath(absPath AbsPath) (AbsPath, error) + ReadDir(dirname AbsPath) ([]os.FileInfo, error) + ReadFile(filename AbsPath) ([]byte, error) + Readlink(name AbsPath) (string, error) + RemoveAll(name AbsPath) error + Rename(oldpath, newpath AbsPath) error + RunCmd(cmd *exec.Cmd) error + RunScript(scriptname RelPath, dir AbsPath, data []byte) error + Stat(name AbsPath) (os.FileInfo, error) + UnderlyingFS() vfs.FS + WriteFile(filename AbsPath, data []byte, perm os.FileMode) error + WriteSymlink(oldname string, newname AbsPath) error +} + +// A emptySystemMixin simulates an empty system. +type emptySystemMixin struct{} + +func (emptySystemMixin) Glob(pattern string) ([]string, error) { return nil, nil } +func (emptySystemMixin) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { return nil, nil } +func (emptySystemMixin) Lstat(name AbsPath) (os.FileInfo, error) { return nil, os.ErrNotExist } +func (emptySystemMixin) RawPath(path AbsPath) (AbsPath, error) { return path, nil } +func (emptySystemMixin) ReadDir(dirname AbsPath) ([]os.FileInfo, error) { return nil, os.ErrNotExist } +func (emptySystemMixin) ReadFile(filename AbsPath) ([]byte, error) { return nil, os.ErrNotExist } +func (emptySystemMixin) Readlink(name AbsPath) (string, error) { return "", os.ErrNotExist } +func (emptySystemMixin) Stat(name AbsPath) (os.FileInfo, error) { return nil, os.ErrNotExist } +func (emptySystemMixin) UnderlyingFS() vfs.FS { return nil } + +// A noUpdateSystemMixin panics on any update. +type noUpdateSystemMixin struct{} + +func (noUpdateSystemMixin) Chmod(name AbsPath, perm os.FileMode) error { panic(nil) } +func (noUpdateSystemMixin) Mkdir(name AbsPath, perm os.FileMode) error { panic(nil) } +func (noUpdateSystemMixin) RemoveAll(name AbsPath) error { panic(nil) } +func (noUpdateSystemMixin) Rename(oldpath, newpath AbsPath) error { panic(nil) } +func (noUpdateSystemMixin) RunCmd(cmd *exec.Cmd) error { panic(nil) } +func (noUpdateSystemMixin) RunScript(scriptname RelPath, dir AbsPath, data []byte) error { panic(nil) } +func (noUpdateSystemMixin) WriteFile(filename AbsPath, data []byte, perm os.FileMode) error { + panic(nil) +} +func (noUpdateSystemMixin) WriteSymlink(oldname string, newname AbsPath) error { panic(nil) } + +// MkdirAll is the equivalent of os.MkdirAll but operates on system. +func MkdirAll(system System, absPath AbsPath, perm os.FileMode) error { + switch err := system.Mkdir(absPath, perm); { + case err == nil: + // Mkdir was successful. + return nil + case os.IsExist(err): + // path already exists, but we don't know whether it's a directory or + // something else. We get this error if we try to create a subdirectory + // of a non-directory, for example if the parent directory of path is a + // file. There's a race condition here between the call to Mkdir and the + // call to Stat but we can't avoid it because there's not enough + // information in the returned error from Mkdir. We need to distinguish + // between "path already exists and is already a directory" and "path + // already exists and is not a directory". Between the call to Mkdir and + // the call to Stat path might have changed. + info, statErr := system.Stat(absPath) + if statErr != nil { + return statErr + } + if !info.IsDir() { + return err + } + return nil + case os.IsNotExist(err): + // Parent directory does not exist. Create the parent directory + // recursively, then try again. + parentDir := absPath.Dir() + if parentDir == "/" || parentDir == "." { + // We cannot create the root directory or the current directory, so + // return the original error. + return err + } + if err := MkdirAll(system, parentDir, perm); err != nil { + return err + } + return system.Mkdir(absPath, perm) + default: + // Some other error. + return err + } +} + +// Walk walks rootAbsPath in s. +func Walk(system System, rootAbsPath AbsPath, walkFn func(absPath AbsPath, info os.FileInfo, err error) error) error { + return vfs.Walk(system.UnderlyingFS(), string(rootAbsPath), func(absPath string, info os.FileInfo, err error) error { + return walkFn(AbsPath(filepath.ToSlash(absPath)), info, err) + }) +} diff --git a/chezmoi2/internal/chezmoi/targetstateentry.go b/chezmoi2/internal/chezmoi/targetstateentry.go new file mode 100644 index 000000000000..9505146a7c63 --- /dev/null +++ b/chezmoi2/internal/chezmoi/targetstateentry.go @@ -0,0 +1,433 @@ +package chezmoi + +import ( + "bytes" + "encoding/hex" + "os" + "time" +) + +// A TargetStateEntry represents the state of an entry in the target state. +type TargetStateEntry interface { + Apply(system System, persistentState PersistentState, actualStateEntry ActualStateEntry, umask os.FileMode) error + EntryState() (*EntryState, error) + Equal(actualStateEntry ActualStateEntry, umask os.FileMode) (bool, error) + Evaluate() error + SkipApply(persistentState PersistentState) (bool, error) +} + +// A TargetStateAbsent represents the absence of an entry in the target state. +type TargetStateAbsent struct{} + +// A TargetStateDir represents the state of a directory in the target state. +type TargetStateDir struct { + perm os.FileMode +} + +// A TargetStateFile represents the state of a file in the target state. +type TargetStateFile struct { + *lazyContents + perm os.FileMode +} + +// A TargetStatePresent represents the presence of an entry in the target state. +type TargetStatePresent struct { + *lazyContents + perm os.FileMode +} + +// A TargetStateRenameDir represents the renaming of a directory in the target +// state. +type TargetStateRenameDir struct { + oldRelPath RelPath + newRelPath RelPath +} + +// A TargetStateScript represents the state of a script. +type TargetStateScript struct { + *lazyContents + name RelPath + once bool +} + +// A TargetStateSymlink represents the state of a symlink in the target state. +type TargetStateSymlink struct { + *lazyLinkname +} + +// A scriptState records the state of a script that has been run. +type scriptState struct { + Name string `json:"name" toml:"name" yaml:"name"` + RunAt time.Time `json:"runAt" toml:"runAt" yaml:"runAt"` +} + +// Apply updates actualStateEntry to match t. +func (t *TargetStateAbsent) Apply(system System, persistentState PersistentState, actualStateEntry ActualStateEntry, umask os.FileMode) error { + if _, ok := actualStateEntry.(*ActualStateAbsent); ok { + return nil + } + return system.RemoveAll(actualStateEntry.Path()) +} + +// EntryState returns t's entry state. +func (t *TargetStateAbsent) EntryState() (*EntryState, error) { + return &EntryState{ + Type: EntryStateTypeAbsent, + }, nil +} + +// Equal returns true if actualStateEntry matches t. +func (t *TargetStateAbsent) Equal(actualStateEntry ActualStateEntry, umask os.FileMode) (bool, error) { + _, ok := actualStateEntry.(*ActualStateAbsent) + if !ok { + return false, nil + } + return ok, nil +} + +// Evaluate evaluates t. +func (t *TargetStateAbsent) Evaluate() error { + return nil +} + +// SkipApply implements TargetState.SkipApply. +func (t *TargetStateAbsent) SkipApply(persistentState PersistentState) (bool, error) { + return false, nil +} + +// Apply updates actualStateEntry to match t. It does not recurse. +func (t *TargetStateDir) Apply(system System, persistentState PersistentState, actualStateEntry ActualStateEntry, umask os.FileMode) error { + if actualStateDir, ok := actualStateEntry.(*ActualStateDir); ok { + if umaskPermEqual(actualStateDir.perm, t.perm, umask) { + return nil + } + return system.Chmod(actualStateDir.Path(), t.perm) + } + if err := actualStateEntry.Remove(system); err != nil { + return err + } + return system.Mkdir(actualStateEntry.Path(), t.perm) +} + +// EntryState returns t's entry state. +func (t *TargetStateDir) EntryState() (*EntryState, error) { + return &EntryState{ + Type: EntryStateTypeDir, + Mode: os.ModeDir | t.perm, + }, nil +} + +// Equal returns true if actualStateEntry matches t. It does not recurse. +func (t *TargetStateDir) Equal(actualStateEntry ActualStateEntry, umask os.FileMode) (bool, error) { + actualStateDir, ok := actualStateEntry.(*ActualStateDir) + if !ok { + return false, nil + } + if !umaskPermEqual(actualStateDir.perm, t.perm, umask) { + return false, nil + } + return true, nil +} + +// Evaluate evaluates t. +func (t *TargetStateDir) Evaluate() error { + return nil +} + +// SkipApply implements TargetState.SkipApply. +func (t *TargetStateDir) SkipApply(persistentState PersistentState) (bool, error) { + return false, nil +} + +// Apply updates actualStateEntry to match t. +func (t *TargetStateFile) Apply(system System, persistentState PersistentState, actualStateEntry ActualStateEntry, umask os.FileMode) error { + if actualStateFile, ok := actualStateEntry.(*ActualStateFile); ok { + // Compare file contents using only their SHA256 sums. This is so that + // we can compare last-written states without storing the full contents + // of each file written. + actualContentsSHA256, err := actualStateFile.ContentsSHA256() + if err != nil { + return err + } + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return err + } + if bytes.Equal(actualContentsSHA256, contentsSHA256) { + if umaskPermEqual(actualStateFile.perm, t.perm, umask) { + return nil + } + return system.Chmod(actualStateFile.Path(), t.perm) + } + } else if err := actualStateEntry.Remove(system); err != nil { + return err + } + contents, err := t.Contents() + if err != nil { + return err + } + return system.WriteFile(actualStateEntry.Path(), contents, t.perm) +} + +// EntryState returns t's entry state. +func (t *TargetStateFile) EntryState() (*EntryState, error) { + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return nil, err + } + return &EntryState{ + Type: EntryStateTypeFile, + Mode: t.perm, + ContentsSHA256: hexBytes(contentsSHA256), + }, nil +} + +// Equal returns true if actualStateEntry matches t. +func (t *TargetStateFile) Equal(actualStateEntry ActualStateEntry, umask os.FileMode) (bool, error) { + actualStateFile, ok := actualStateEntry.(*ActualStateFile) + if !ok { + return false, nil + } + if !umaskPermEqual(actualStateFile.perm, t.perm, umask) { + return false, nil + } + actualContentsSHA256, err := actualStateFile.ContentsSHA256() + if err != nil { + return false, err + } + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return false, err + } + if !bytes.Equal(actualContentsSHA256, contentsSHA256) { + return false, nil + } + return true, nil +} + +// Evaluate evaluates t. +func (t *TargetStateFile) Evaluate() error { + _, err := t.ContentsSHA256() + return err +} + +// SkipApply implements TargetState.SkipApply. +func (t *TargetStateFile) SkipApply(persistentState PersistentState) (bool, error) { + return false, nil +} + +// Apply updates actualStateEntry to match t. +func (t *TargetStatePresent) Apply(system System, persistentState PersistentState, actualStateEntry ActualStateEntry, umask os.FileMode) error { + if actualStateFile, ok := actualStateEntry.(*ActualStateFile); ok { + if umaskPermEqual(actualStateFile.perm, t.perm, umask) { + return nil + } + return system.Chmod(actualStateFile.Path(), t.perm) + } else if err := actualStateEntry.Remove(system); err != nil { + return err + } + contents, err := t.Contents() + if err != nil { + return err + } + return system.WriteFile(actualStateEntry.Path(), contents, t.perm) +} + +// EntryState returns t's entry state. +func (t *TargetStatePresent) EntryState() (*EntryState, error) { + return &EntryState{ + Type: EntryStateTypePresent, + }, nil +} + +// Equal returns true if actualStateEntry matches t. +func (t *TargetStatePresent) Equal(actualStateEntry ActualStateEntry, umask os.FileMode) (bool, error) { + actualStateFile, ok := actualStateEntry.(*ActualStateFile) + if !ok { + return false, nil + } + if !umaskPermEqual(actualStateFile.perm, t.perm, umask) { + return false, nil + } + return true, nil +} + +// Evaluate evaluates t. +func (t *TargetStatePresent) Evaluate() error { + _, err := t.ContentsSHA256() + return err +} + +// SkipApply implements TargetState.SkipApply. +func (t *TargetStatePresent) SkipApply(persistentState PersistentState) (bool, error) { + return false, nil +} + +// Apply renames actualStateEntry. +func (t *TargetStateRenameDir) Apply(system System, persistentState PersistentState, actualStateEntry ActualStateEntry, umask os.FileMode) error { + dir := actualStateEntry.Path().Dir() + return system.Rename(dir.Join(t.oldRelPath), dir.Join(t.newRelPath)) +} + +// EntryState returns t's entry state. +func (t *TargetStateRenameDir) EntryState() (*EntryState, error) { + return nil, nil +} + +// Equal returns false because actualStateEntry has not been renamed. +func (t *TargetStateRenameDir) Equal(actualStateEntry ActualStateEntry, umask os.FileMode) (bool, error) { + return false, nil +} + +// Evaluate does nothing. +func (t *TargetStateRenameDir) Evaluate() error { + return nil +} + +// SkipApply implements TargetState.SkipApply. +func (t *TargetStateRenameDir) SkipApply(persistentState PersistentState) (bool, error) { + return false, nil +} + +// Apply runs t. +func (t *TargetStateScript) Apply(system System, persistentState PersistentState, actualStateEntry ActualStateEntry, umask os.FileMode) error { + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return err + } + key := []byte(hex.EncodeToString(contentsSHA256)) + if t.once { + switch scriptState, err := persistentState.Get(scriptStateBucket, key); { + case err != nil: + return err + case scriptState != nil: + return nil + } + } + contents, err := t.Contents() + if err != nil { + return err + } + runAt := time.Now().UTC() + if !isEmpty(contents) { + if err := system.RunScript(t.name, actualStateEntry.Path().Dir(), contents); err != nil { + return err + } + } + return persistentStateSet(persistentState, scriptStateBucket, key, &scriptState{ + Name: string(t.name), + RunAt: runAt, + }) +} + +// EntryState returns t's entry state. +func (t *TargetStateScript) EntryState() (*EntryState, error) { + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return nil, err + } + return &EntryState{ + Type: EntryStateTypeScript, + ContentsSHA256: hexBytes(contentsSHA256), + }, nil +} + +// Equal returns true if actualStateEntry matches t. +func (t *TargetStateScript) Equal(actualStateEntry ActualStateEntry, umask os.FileMode) (bool, error) { + // Scripts are independent of the actual state. + return true, nil +} + +// Evaluate evaluates t. +func (t *TargetStateScript) Evaluate() error { + _, err := t.ContentsSHA256() + return err +} + +// SkipApply implements TargetState.SkipApply. +func (t *TargetStateScript) SkipApply(persistentState PersistentState) (bool, error) { + if !t.once { + return false, nil + } + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return false, err + } + key := []byte(hex.EncodeToString(contentsSHA256)) + switch scriptState, err := persistentState.Get(scriptStateBucket, key); { + case err != nil: + return false, err + case scriptState != nil: + return true, nil + default: + return false, nil + } +} + +// Apply updates actualStateEntry to match t. +func (t *TargetStateSymlink) Apply(system System, persistentState PersistentState, actualStateEntry ActualStateEntry, umask os.FileMode) error { + if actualStateSymlink, ok := actualStateEntry.(*ActualStateSymlink); ok { + actualLinkname, err := actualStateSymlink.Linkname() + if err != nil { + return err + } + linkname, err := t.Linkname() + if err != nil { + return err + } + if actualLinkname == linkname { + return nil + } + } + linkname, err := t.Linkname() + if err != nil { + return err + } + if err := actualStateEntry.Remove(system); err != nil { + return err + } + return system.WriteSymlink(linkname, actualStateEntry.Path()) +} + +// EntryState returns t's entry state. +func (t *TargetStateSymlink) EntryState() (*EntryState, error) { + linknameSHA256, err := t.LinknameSHA256() + if err != nil { + return nil, err + } + return &EntryState{ + Type: EntryStateTypeSymlink, + ContentsSHA256: linknameSHA256, + }, nil +} + +// Equal returns true if actualStateEntry matches t. +func (t *TargetStateSymlink) Equal(actualStateEntry ActualStateEntry, umask os.FileMode) (bool, error) { + actualStateSymlink, ok := actualStateEntry.(*ActualStateSymlink) + if !ok { + return false, nil + } + actualLinkname, err := actualStateSymlink.Linkname() + if err != nil { + return false, err + } + linkname, err := t.Linkname() + if err != nil { + return false, nil + } + if actualLinkname != linkname { + return false, nil + } + return true, nil +} + +// Evaluate evaluates t. +func (t *TargetStateSymlink) Evaluate() error { + _, err := t.Linkname() + return err +} + +// SkipApply implements TargetState.SkipApply. +func (t *TargetStateSymlink) SkipApply(persistentState PersistentState) (bool, error) { + return false, nil +} diff --git a/chezmoi2/internal/chezmoi/targetstateentry_test.go b/chezmoi2/internal/chezmoi/targetstateentry_test.go new file mode 100644 index 000000000000..67dd75542774 --- /dev/null +++ b/chezmoi2/internal/chezmoi/targetstateentry_test.go @@ -0,0 +1,176 @@ +package chezmoi + +import ( + "fmt" + "os" + "sort" + "testing" + + "github.com/muesli/combinator" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +func TestTargetStateEntryApplyAndEqual(t *testing.T) { + targetStates := map[string]TargetStateEntry{ + "absent": &TargetStateAbsent{}, + "dir": &TargetStateDir{ + perm: 0o777, + }, + "file": &TargetStateFile{ + perm: 0o666, + lazyContents: &lazyContents{ + contents: []byte("# contents of file"), + }, + }, + "file_empty": &TargetStateFile{ + perm: 0o666, + }, + "file_executable": &TargetStateFile{ + perm: 0o777, + lazyContents: &lazyContents{ + contents: []byte("#!/bin/sh\n"), + }, + }, + "present": &TargetStatePresent{ + perm: 0o666, + }, + "symlink": &TargetStateSymlink{ + lazyLinkname: &lazyLinkname{ + linkname: "target", + }, + }, + } + + actualStates := map[string]map[string]interface{}{ + "absent": { + "/home/user": &vfst.Dir{Perm: 0o777}, + }, + "dir": { + "/home/user/target": &vfst.Dir{Perm: 0o777}, + }, + "file": { + "/home/user/target": "# contents of file", + }, + "file_empty": { + "/home/user/target": "", + }, + "file_executable": { + "/home/user/target": &vfst.File{ + Perm: 0o777, + Contents: []byte("!/bin/sh\n"), + }, + }, + "symlink": { + "/home/user": map[string]interface{}{ + "symlink-target": "", + "target": &vfst.Symlink{Target: "symlink-target"}, + }, + }, + "symlink_broken": { + "/home/user/target": &vfst.Symlink{Target: "symlink-target"}, + }, + } + + targetStateKeys := make([]string, 0, len(targetStates)) + for targetStateKey := range targetStates { + targetStateKeys = append(targetStateKeys, targetStateKey) + } + sort.Strings(targetStateKeys) + + actualDestDirStateKeys := make([]string, 0, len(actualStates)) + for actualDestDirStateKey := range actualStates { + actualDestDirStateKeys = append(actualDestDirStateKeys, actualDestDirStateKey) + } + sort.Strings(actualDestDirStateKeys) + + testData := struct { + TargetStateKey []string + ActualDestDirStateKey []string + }{ + TargetStateKey: targetStateKeys, + ActualDestDirStateKey: actualDestDirStateKeys, + } + var testCases []struct { + TargetStateKey string + ActualDestDirStateKey string + } + require.NoError(t, combinator.Generate(&testCases, testData)) + + for _, tc := range testCases { + t.Run(fmt.Sprintf("target_%s_actual_%s", tc.TargetStateKey, tc.ActualDestDirStateKey), func(t *testing.T) { + targetState := targetStates[tc.TargetStateKey] + actualState := actualStates[tc.ActualDestDirStateKey] + + chezmoitest.WithTestFS(t, actualState, func(fs vfs.FS) { + s := NewRealSystem(fs) + + // Read the initial destination state entry from fs. + actualStateEntry, err := NewActualStateEntry(s, "/home/user/target", nil, nil) + require.NoError(t, err) + + // Apply the target state entry. + require.NoError(t, targetState.Apply(s, nil, actualStateEntry, GetUmask())) + + // Verify that the actual state entry matches the desired + // state. + vfst.RunTests(t, fs, "", vfst.TestPath("/home/user/target", targetStateTest(t, targetState)...)) + + // Read the updated destination state entry from fs and + // verify that it is equal to the target state entry. + newActualStateEntry, err := NewActualStateEntry(s, "/home/user/target", nil, nil) + require.NoError(t, err) + equal, err := targetState.Equal(newActualStateEntry, GetUmask()) + require.NoError(t, err) + require.True(t, equal) + }) + }) + } +} + +func targetStateTest(t *testing.T, ts TargetStateEntry) []vfst.PathTest { + t.Helper() + switch ts := ts.(type) { + case *TargetStateAbsent: + return []vfst.PathTest{ + vfst.TestDoesNotExist, + } + case *TargetStateDir: + return []vfst.PathTest{ + vfst.TestIsDir, + vfst.TestModePerm(ts.perm &^ GetUmask()), + } + case *TargetStateFile: + expectedContents, err := ts.Contents() + require.NoError(t, err) + return []vfst.PathTest{ + vfst.TestModeIsRegular, + vfst.TestContents(expectedContents), + vfst.TestModePerm(ts.perm &^ GetUmask()), + } + case *TargetStatePresent: + return []vfst.PathTest{ + vfst.TestModeIsRegular, + vfst.TestModePerm(ts.perm &^ GetUmask()), + } + case *TargetStateRenameDir: + // FIXME test for presence of newName + return []vfst.PathTest{ + vfst.TestDoesNotExist, + } + case *TargetStateScript: + return nil // FIXME how to verify scripts? + case *TargetStateSymlink: + expectedLinkname, err := ts.Linkname() + require.NoError(t, err) + return []vfst.PathTest{ + vfst.TestModeType(os.ModeSymlink), + vfst.TestSymlinkTarget(expectedLinkname), + } + default: + return nil + } +} diff --git a/chezmoi2/internal/chezmoi/tarreadersystem.go b/chezmoi2/internal/chezmoi/tarreadersystem.go new file mode 100644 index 000000000000..6062de5dcb05 --- /dev/null +++ b/chezmoi2/internal/chezmoi/tarreadersystem.go @@ -0,0 +1,110 @@ +package chezmoi + +import ( + "archive/tar" + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "strings" +) + +// A TARReaderSystem a system constructed from reading a TAR archive. +type TARReaderSystem struct { + emptySystemMixin + noUpdateSystemMixin + fileInfos map[AbsPath]os.FileInfo + contents map[AbsPath][]byte + linkname map[AbsPath]string +} + +// TARReaderSystemOptions are options to NewTARReaderSystem. +type TARReaderSystemOptions struct { + RootAbsPath AbsPath + StripComponents int +} + +// NewTARReaderSystem returns a new TARReaderSystem from tarReader. +func NewTARReaderSystem(tarReader *tar.Reader, options TARReaderSystemOptions) (*TARReaderSystem, error) { + s := &TARReaderSystem{ + fileInfos: make(map[AbsPath]os.FileInfo), + contents: make(map[AbsPath][]byte), + linkname: make(map[AbsPath]string), + } +FOR: + for { + header, err := tarReader.Next() + switch { + case errors.Is(err, io.EOF): + return s, nil + case err != nil: + return nil, err + } + + name := strings.TrimSuffix(header.Name, "/") + if options.StripComponents > 0 { + components := strings.Split(name, "/") + if len(components) <= options.StripComponents { + continue FOR + } + name = strings.Join(components[options.StripComponents:], "/") + } + nameAbsPath := options.RootAbsPath.Join(RelPath(name)) + + switch header.Typeflag { + case tar.TypeDir: + s.fileInfos[nameAbsPath] = header.FileInfo() + case tar.TypeReg: + s.fileInfos[nameAbsPath] = header.FileInfo() + contents, err := ioutil.ReadAll(tarReader) + if err != nil { + return nil, err + } + s.contents[nameAbsPath] = contents + case tar.TypeSymlink: + s.fileInfos[nameAbsPath] = header.FileInfo() + s.linkname[nameAbsPath] = header.Linkname + case tar.TypeXGlobalHeader: + continue FOR + default: + return nil, fmt.Errorf("unsupported typeflag '%c'", header.Typeflag) + } + } +} + +// FileInfos retunrs s's os.FileInfos. +func (s *TARReaderSystem) FileInfos() map[AbsPath]os.FileInfo { + return s.fileInfos +} + +// Lstat implements System.Lstat. +func (s *TARReaderSystem) Lstat(filename AbsPath) (os.FileInfo, error) { + fileInfo, ok := s.fileInfos[filename] + if !ok { + return nil, os.ErrNotExist + } + return fileInfo, nil +} + +// ReadFile implements System.ReadFile. +func (s *TARReaderSystem) ReadFile(filename AbsPath) ([]byte, error) { + if contents, ok := s.contents[filename]; ok { + return contents, nil + } + if _, ok := s.fileInfos[filename]; ok { + return nil, os.ErrInvalid + } + return nil, os.ErrNotExist +} + +// Readlink implements System.Readlink. +func (s *TARReaderSystem) Readlink(name AbsPath) (string, error) { + if linkname, ok := s.linkname[name]; ok { + return linkname, nil + } + if _, ok := s.fileInfos[name]; ok { + return "", os.ErrInvalid + } + return "", os.ErrNotExist +} diff --git a/chezmoi2/internal/chezmoi/tarreadersystem_test.go b/chezmoi2/internal/chezmoi/tarreadersystem_test.go new file mode 100644 index 000000000000..fb741bfe170f --- /dev/null +++ b/chezmoi2/internal/chezmoi/tarreadersystem_test.go @@ -0,0 +1,93 @@ +package chezmoi + +import ( + "archive/tar" + "bytes" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTARReaderSystem(t *testing.T) { + b := &bytes.Buffer{} + w := tar.NewWriter(b) + assert.NoError(t, w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeDir, + Name: "dir/", + Mode: 0o777, + })) + data := []byte("# contents of dir/file\n") + assert.NoError(t, w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: "dir/file", + Size: int64(len(data)), + Mode: 0o666, + })) + _, err := w.Write(data) + assert.NoError(t, err) + linkname := "file" + assert.NoError(t, w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeSymlink, + Name: "dir/symlink", + Linkname: linkname, + })) + require.NoError(t, w.Close()) + + tarReaderSystem, err := NewTARReaderSystem(tar.NewReader(b), TARReaderSystemOptions{ + RootAbsPath: "/home/user", + StripComponents: 1, + }) + assert.NoError(t, err) + + for _, tc := range []struct { + absPath AbsPath + lstatErr error + readlink string + readlinkErr error + readFileData []byte + readFileErr error + }{ + { + absPath: "/home/user/file", + readlinkErr: os.ErrInvalid, + readFileData: data, + }, + { + absPath: "/home/user/notexist", + readlinkErr: os.ErrNotExist, + lstatErr: os.ErrNotExist, + readFileErr: os.ErrNotExist, + }, + { + absPath: "/home/user/symlink", + readlink: "file", + readFileErr: os.ErrInvalid, + }, + } { + _, err = tarReaderSystem.Lstat(tc.absPath) + if tc.lstatErr != nil { + assert.True(t, errors.Is(err, tc.lstatErr)) + } else { + assert.NoError(t, err) + } + + actualLinkname, err := tarReaderSystem.Readlink(tc.absPath) + if tc.readlinkErr != nil { + assert.True(t, errors.Is(err, tc.readlinkErr)) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.readlink, actualLinkname) + } + + actualReadFileData, err := tarReaderSystem.ReadFile(tc.absPath) + if tc.readFileErr != nil { + assert.True(t, errors.Is(err, tc.readFileErr)) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.readFileData, actualReadFileData) + } + } +} diff --git a/chezmoi2/internal/chezmoi/tarwritersystem.go b/chezmoi2/internal/chezmoi/tarwritersystem.go new file mode 100644 index 000000000000..9adc69f23c5c --- /dev/null +++ b/chezmoi2/internal/chezmoi/tarwritersystem.go @@ -0,0 +1,65 @@ +package chezmoi + +import ( + "archive/tar" + "io" + "os" +) + +// A TARWriterSystem is a System that writes to a TAR archive. +type TARWriterSystem struct { + emptySystemMixin + noUpdateSystemMixin + w *tar.Writer + headerTemplate tar.Header +} + +// NewTARWriterSystem returns a new TARWriterSystem that writes a TAR file to w. +func NewTARWriterSystem(w io.Writer, headerTemplate tar.Header) *TARWriterSystem { + return &TARWriterSystem{ + w: tar.NewWriter(w), + headerTemplate: headerTemplate, + } +} + +// Close closes m. +func (s *TARWriterSystem) Close() error { + return s.w.Close() +} + +// Mkdir implements System.Mkdir. +func (s *TARWriterSystem) Mkdir(name AbsPath, perm os.FileMode) error { + header := s.headerTemplate + header.Typeflag = tar.TypeDir + header.Name = string(name) + "/" + header.Mode = int64(perm) + return s.w.WriteHeader(&header) +} + +// RunScript implements System.RunScript. +func (s *TARWriterSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte) error { + return s.WriteFile(AbsPath(scriptname), data, 0o700) +} + +// WriteFile implements System.WriteFile. +func (s *TARWriterSystem) WriteFile(filename AbsPath, data []byte, perm os.FileMode) error { + header := s.headerTemplate + header.Typeflag = tar.TypeReg + header.Name = string(filename) + header.Size = int64(len(data)) + header.Mode = int64(perm) + if err := s.w.WriteHeader(&header); err != nil { + return err + } + _, err := s.w.Write(data) + return err +} + +// WriteSymlink implements System.WriteSymlink. +func (s *TARWriterSystem) WriteSymlink(oldname string, newname AbsPath) error { + header := s.headerTemplate + header.Typeflag = tar.TypeSymlink + header.Name = string(newname) + header.Linkname = oldname + return s.w.WriteHeader(&header) +} diff --git a/chezmoi2/internal/chezmoi/tarwritersystem_test.go b/chezmoi2/internal/chezmoi/tarwritersystem_test.go new file mode 100644 index 000000000000..26fd8e19aab4 --- /dev/null +++ b/chezmoi2/internal/chezmoi/tarwritersystem_test.go @@ -0,0 +1,98 @@ +package chezmoi + +import ( + "archive/tar" + "bytes" + "io" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +var _ System = &TARWriterSystem{} + +func TestTARWriterSystem(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + ".chezmoiremove": "*.txt\n", + ".chezmoiversion": "1.2.3\n", + ".chezmoitemplates": map[string]interface{}{ + "template": "# contents of .chezmoitemplates/template\n", + }, + "README.md": "", + "dot_dir": map[string]interface{}{ + "file": "# contents of .dir/file\n", + }, + "run_script": "# contents of script\n", + "symlink_symlink": ".dir/subdir/file\n", + }, + }, func(fs vfs.FS) { + s := NewSourceState( + WithSourceDir("/home/user/.local/share/chezmoi"), + WithSystem(NewRealSystem(fs)), + ) + require.NoError(t, s.Read()) + require.NoError(t, s.evaluateAll()) + + b := &bytes.Buffer{} + tarWriterSystem := NewTARWriterSystem(b, tar.Header{}) + persistentState := NewMockPersistentState() + require.NoError(t, s.applyAll(tarWriterSystem, persistentState, "", ApplyOptions{})) + require.NoError(t, tarWriterSystem.Close()) + + r := tar.NewReader(b) + for _, tc := range []struct { + expectedTypeflag byte + expectedName string + expectedMode int64 + expectedLinkname string + expectedContents []byte + }{ + { + expectedTypeflag: tar.TypeDir, + expectedName: ".dir/", + expectedMode: 0o777, + }, + { + expectedTypeflag: tar.TypeReg, + expectedName: ".dir/file", + expectedContents: []byte("# contents of .dir/file\n"), + expectedMode: 0o666, + }, + { + expectedTypeflag: tar.TypeReg, + expectedName: "script", + expectedContents: []byte("# contents of script\n"), + expectedMode: 0o700, + }, + { + expectedTypeflag: tar.TypeSymlink, + expectedName: "symlink", + expectedLinkname: ".dir/subdir/file", + }, + } { + t.Run(tc.expectedName, func(t *testing.T) { + header, err := r.Next() + require.NoError(t, err) + assert.Equal(t, tc.expectedTypeflag, header.Typeflag) + assert.Equal(t, tc.expectedName, header.Name) + assert.Equal(t, tc.expectedMode, header.Mode) + assert.Equal(t, tc.expectedLinkname, header.Linkname) + assert.Equal(t, int64(len(tc.expectedContents)), header.Size) + if tc.expectedContents != nil { + actualContents, err := ioutil.ReadAll(r) + require.NoError(t, err) + assert.Equal(t, tc.expectedContents, actualContents) + } + }) + } + _, err := r.Next() + assert.Equal(t, io.EOF, err) + }) +} diff --git a/chezmoi2/internal/chezmoi/zipwritersystem.go b/chezmoi2/internal/chezmoi/zipwritersystem.go new file mode 100644 index 000000000000..b19445b80087 --- /dev/null +++ b/chezmoi2/internal/chezmoi/zipwritersystem.go @@ -0,0 +1,80 @@ +package chezmoi + +import ( + "archive/zip" + "io" + "os" + "time" +) + +// A ZIPWriterSystem is a System that writes to a ZIP archive. +type ZIPWriterSystem struct { + emptySystemMixin + noUpdateSystemMixin + w *zip.Writer + modified time.Time +} + +// NewZIPWriterSystem returns a new ZIPWriterSystem that writes a ZIP archive to +// w. +func NewZIPWriterSystem(w io.Writer, modified time.Time) *ZIPWriterSystem { + return &ZIPWriterSystem{ + w: zip.NewWriter(w), + modified: modified, + } +} + +// Close closes m. +func (s *ZIPWriterSystem) Close() error { + return s.w.Close() +} + +// Mkdir implements System.Mkdir. +func (s *ZIPWriterSystem) Mkdir(name AbsPath, perm os.FileMode) error { + fh := zip.FileHeader{ + Name: string(name), + Modified: s.modified, + } + fh.SetMode(os.ModeDir | perm) + _, err := s.w.CreateHeader(&fh) + return err +} + +// RunScript implements System.RunScript. +func (s *ZIPWriterSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte) error { + return s.WriteFile(AbsPath(scriptname), data, 0o700) +} + +// WriteFile implements System.WriteFile. +func (s *ZIPWriterSystem) WriteFile(filename AbsPath, data []byte, perm os.FileMode) error { + fh := zip.FileHeader{ + Name: string(filename), + Method: zip.Deflate, + Modified: s.modified, + UncompressedSize64: uint64(len(data)), + } + fh.SetMode(perm) + fw, err := s.w.CreateHeader(&fh) + if err != nil { + return err + } + _, err = fw.Write(data) + return err +} + +// WriteSymlink implements System.WriteSymlink. +func (s *ZIPWriterSystem) WriteSymlink(oldname string, newname AbsPath) error { + data := []byte(oldname) + fh := zip.FileHeader{ + Name: string(newname), + Modified: s.modified, + UncompressedSize64: uint64(len(data)), + } + fh.SetMode(os.ModeSymlink) + fw, err := s.w.CreateHeader(&fh) + if err != nil { + return err + } + _, err = fw.Write(data) + return err +} diff --git a/chezmoi2/internal/chezmoi/zipwritersystem_test.go b/chezmoi2/internal/chezmoi/zipwritersystem_test.go new file mode 100644 index 000000000000..14a979ec8773 --- /dev/null +++ b/chezmoi2/internal/chezmoi/zipwritersystem_test.go @@ -0,0 +1,97 @@ +package chezmoi + +import ( + "archive/zip" + "bytes" + "io/ioutil" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +var _ System = &ZIPWriterSystem{} + +func TestZIPWriterSystem(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + ".chezmoiremove": "*.txt\n", + ".chezmoiversion": "1.2.3\n", + ".chezmoitemplates": map[string]interface{}{ + "template": "# contents of .chezmoitemplates/template\n", + }, + "README.md": "", + "dot_dir": map[string]interface{}{ + "file": "# contents of .dir/file\n", + }, + "run_script": "# contents of script\n", + "symlink_symlink": ".dir/subdir/file\n", + }, + }, func(fs vfs.FS) { + s := NewSourceState( + WithSourceDir("/home/user/.local/share/chezmoi"), + WithSystem(NewRealSystem(fs)), + ) + require.NoError(t, s.Read()) + require.NoError(t, s.evaluateAll()) + + b := &bytes.Buffer{} + zipWriterSystem := NewZIPWriterSystem(b, time.Now().UTC()) + persistentState := NewMockPersistentState() + require.NoError(t, s.applyAll(zipWriterSystem, persistentState, "", ApplyOptions{})) + require.NoError(t, zipWriterSystem.Close()) + + r, err := zip.NewReader(bytes.NewReader(b.Bytes()), int64(b.Len())) + require.NoError(t, err) + expectedFiles := []struct { + name string + method uint16 + mode os.FileMode + contents []byte + }{ + { + name: ".dir", + mode: os.ModeDir | 0o777, + }, + { + name: ".dir/file", + method: zip.Deflate, + mode: 0o666, + contents: []byte("# contents of .dir/file\n"), + }, + { + name: "script", + method: zip.Deflate, + mode: 0o700, + contents: []byte("# contents of script\n"), + }, + { + name: "symlink", + mode: os.ModeSymlink, + contents: []byte(".dir/subdir/file"), + }, + } + require.Len(t, r.File, len(expectedFiles)) + for i, expectedFile := range expectedFiles { + t.Run(expectedFile.name, func(t *testing.T) { + actualFile := r.File[i] + assert.Equal(t, expectedFile.name, actualFile.Name) + assert.Equal(t, expectedFile.method, actualFile.Method) + assert.Equal(t, expectedFile.mode, actualFile.Mode()) + if expectedFile.contents != nil { + rc, err := actualFile.Open() + require.NoError(t, err) + actualContents, err := ioutil.ReadAll(rc) + require.NoError(t, err) + assert.Equal(t, expectedFile.contents, actualContents) + } + }) + } + }) +} diff --git a/chezmoi2/internal/chezmoilog/chezmoilog.go b/chezmoi2/internal/chezmoilog/chezmoilog.go new file mode 100644 index 000000000000..a0077266083a --- /dev/null +++ b/chezmoi2/internal/chezmoilog/chezmoilog.go @@ -0,0 +1,130 @@ +package chezmoilog + +import ( + "errors" + "os" + "os/exec" + + "github.com/rs/zerolog" +) + +// An OSExecCmdLogObject wraps an *os/exec.Cmd and adds +// github.com/rs/zerolog.LogObjectMarshaler functionality. +type OSExecCmdLogObject struct { + *exec.Cmd +} + +// An OSExecExitErrorLogObject wraps an error and adds +// github.com/rs/zerolog.LogObjectMarshaler functionality if the wrapped error +// is an os/exec.ExitError. +type OSExecExitErrorLogObject struct { + Err error +} + +// An OSProcessStateLogObject wraps an *os.ProcessState and adds +// github.com/rs/zerolog.LogObjectMarshaler functionality. +type OSProcessStateLogObject struct { + *os.ProcessState +} + +// MarshalZerologObject implements +// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. +func (cmd OSExecCmdLogObject) MarshalZerologObject(event *zerolog.Event) { + if cmd.Cmd == nil { + return + } + if cmd.Path != "" { + event.Str("path", cmd.Path) + } + if cmd.Args != nil { + event.Strs("args", cmd.Args) + } + if cmd.Dir != "" { + event.Str("dir", cmd.Dir) + } + if cmd.Env != nil { + event.Strs("env", cmd.Env) + } +} + +// MarshalZerologObject implements +// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. +func (err OSExecExitErrorLogObject) MarshalZerologObject(event *zerolog.Event) { + if err.Err == nil { + return + } + var osExecExitError *exec.ExitError + if !errors.As(err.Err, &osExecExitError) { + return + } + event.EmbedObject(OSProcessStateLogObject{osExecExitError.ProcessState}) + if osExecExitError.Stderr != nil { + event.Bytes("stderr", osExecExitError.Stderr) + } +} + +// MarshalZerologObject implements +// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. +func (p OSProcessStateLogObject) MarshalZerologObject(event *zerolog.Event) { + if p.ProcessState == nil { + return + } + if p.Exited() { + if !p.Success() { + event.Int("exitCode", p.ExitCode()) + } + } else { + event.Int("pid", p.Pid()) + } + if userTime := p.UserTime(); userTime != 0 { + event.Dur("userTime", userTime) + } + if systemTime := p.SystemTime(); systemTime != 0 { + event.Dur("systemTime", systemTime) + } +} + +// FirstFewBytes returns the first few bytes of data in a human-readable form. +func FirstFewBytes(data []byte) []byte { + const few = 64 + if len(data) > few { + data = append([]byte{}, data[:few]...) + data = append(data, '.', '.', '.') + } + return data +} + +// LogCmdCombinedOutput calls cmd.CombinedOutput, logs the result, and returns the result. +func LogCmdCombinedOutput(logger zerolog.Logger, cmd *exec.Cmd) ([]byte, error) { + combinedOutput, err := cmd.CombinedOutput() + logger.Debug(). + EmbedObject(OSExecCmdLogObject{Cmd: cmd}). + Err(err). + EmbedObject(OSExecExitErrorLogObject{Err: err}). + Bytes("combinedOutput", FirstFewBytes(combinedOutput)). + Msg("CombinedOutput") + return combinedOutput, err +} + +// LogCmdOutput calls cmd.Output, logs the result, and returns the result. +func LogCmdOutput(logger zerolog.Logger, cmd *exec.Cmd) ([]byte, error) { + output, err := cmd.Output() + logger.Debug(). + EmbedObject(OSExecCmdLogObject{Cmd: cmd}). + Err(err). + EmbedObject(OSExecExitErrorLogObject{Err: err}). + Bytes("output", FirstFewBytes(output)). + Msg("Output") + return output, err +} + +// LogCmdRun calls cmd.Run, logs the result, and returns the result. +func LogCmdRun(logger zerolog.Logger, cmd *exec.Cmd) error { + err := cmd.Run() + logger.Debug(). + EmbedObject(OSExecCmdLogObject{Cmd: cmd}). + Err(err). + EmbedObject(OSExecExitErrorLogObject{Err: err}). + Msg("Run") + return err +} diff --git a/chezmoi2/internal/chezmoitest/chezmoitest.go b/chezmoi2/internal/chezmoitest/chezmoitest.go new file mode 100644 index 000000000000..ff7ed9a4f714 --- /dev/null +++ b/chezmoi2/internal/chezmoitest/chezmoitest.go @@ -0,0 +1,134 @@ +package chezmoitest + +import ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoilog" +) + +var ( + agePublicKeyRx = regexp.MustCompile(`(?m)^Public key: ([0-9a-z]+)\s*$`) + gpgKeyMarkedAsUltimatelyTrustedRx = regexp.MustCompile(`(?m)^gpg: key ([0-9A-F]+) marked as ultimately trusted\s*$`) +) + +// AGEGenerateKey generates and returns an age public key and the path to the +// private key. If filename is non-zero then the private key is written to it, +// otherwise a new file is created in a temporary directory and the caller is +// responsible for removing the temporary directory. +func AGEGenerateKey(filename string) (publicKey, privateKeyFile string, err error) { + if filename == "" { + var tempDir string + tempDir, err = ioutil.TempDir("", "chezmoi-test-age-key") + if err != nil { + return "", "", err + } + defer func() { + if err != nil { + os.RemoveAll(tempDir) + } + }() + if runtime.GOOS != "windows" { + if err = os.Chmod(tempDir, 0o700); err != nil { + return + } + } + filename = filepath.Join(tempDir, "key.txt") + } + + privateKeyFile = filename + var output []byte + cmd := exec.Command("age-keygen", "--output", privateKeyFile) + output, err = chezmoilog.LogCmdCombinedOutput(log.Logger, cmd) + if err != nil { + return + } + match := agePublicKeyRx.FindSubmatch(output) + if match == nil { + err = fmt.Errorf("public key not found in %q", output) + return + } + publicKey = string(match[1]) + return +} + +// GPGCommand returns the GPG command, if it can be found. +func GPGCommand() (string, error) { + return exec.LookPath("gpg") +} + +// GPGGenerateKey generates and returns a GPG key in homeDir. +func GPGGenerateKey(command, homeDir string) (key, passphrase string, err error) { + //nolint:gosec + passphrase = "chezmoi-test-gpg-passphrase" + cmd := exec.Command( + command, + "--batch", + "--homedir", homeDir, + "--no-tty", + "--passphrase", passphrase, + "--pinentry-mode", "loopback", + "--quick-generate-key", "chezmoi-test-gpg-key", + ) + output, err := chezmoilog.LogCmdCombinedOutput(log.Logger, cmd) + if err != nil { + return "", "", err + } + submatch := gpgKeyMarkedAsUltimatelyTrustedRx.FindSubmatch(output) + if submatch == nil { + return "", "", fmt.Errorf("key not found in %q", output) + } + return string(submatch[1]), passphrase, nil +} + +// GitHubActionsOnWindows returns if running in GitHub Actions on Windows. +func GitHubActionsOnWindows() bool { + return runtime.GOOS == "windows" && os.Getenv("GITHUB_ACTIONS") == "true" +} + +// HomeDir returns the home directory. +func HomeDir() string { + switch runtime.GOOS { + case "windows": + return "C:/home/user" + default: + return "/home/user" + } +} + +// JoinLines joins lines with newlines. +func JoinLines(lines ...string) string { + return strings.Join(lines, "\n") + "\n" +} + +// SkipUnlessGOOS calls t.Skip() if name does not match runtime.GOOS. +func SkipUnlessGOOS(t *testing.T, name string) { + t.Helper() + switch { + case strings.HasSuffix(name, "_windows") && runtime.GOOS != "windows": + t.Skip("skipping Windows test on UNIX") + case strings.HasSuffix(name, "_unix") && runtime.GOOS == "windows": + t.Skip("skipping UNIX test on Windows") + } +} + +// WithTestFS calls f with a test filesystem populated with root. +func WithTestFS(t *testing.T, root interface{}, f func(fs vfs.FS)) { + t.Helper() + fs, cleanup, err := vfst.NewTestFS(root) + require.NoError(t, err) + t.Cleanup(cleanup) + f(fs) +} diff --git a/chezmoi2/main.go b/chezmoi2/main.go new file mode 100644 index 000000000000..312f06cdfb2b --- /dev/null +++ b/chezmoi2/main.go @@ -0,0 +1,33 @@ +//go:generate go run ../internal/cmd/generate-assets -o cmd/docs.gen.go -tags=!noembeddocs -trimprefix=../ ../docs/CHANGES.md ../docs/CONTRIBUTING.md ../docs/FAQ.md ../docs/HOWTO.md ../docs/INSTALL.md ../docs/MEDIA.md ../docs/QUICKSTART.md ../docs/REFERENCE.md +//go:generate go run ../internal/cmd/generate-assets -o cmd/templates.gen.go -trimprefix=../ ../assets/templates/COMMIT_MESSAGE.tmpl +//go:generate go run ../internal/cmd/generate-helps -o cmd/helps.gen.go -i ../docs/REFERENCE.md +//go:generate go run . completion bash -o completions/chezmoi-completion.bash +//go:generate go run . completion fish -o completions/chezmoi.fish +//go:generate go run . completion powershell -o completions/chezmoi.ps1 +//go:generate go run . completion zsh -o completions/chezmoi.zsh + +package main + +import ( + "os" + + "github.com/twpayne/chezmoi/chezmoi2/cmd" +) + +var ( + version string + commit string + date string + builtBy string +) + +func main() { + if exitCode := cmd.Main(cmd.VersionInfo{ + Version: version, + Commit: commit, + Date: date, + BuiltBy: builtBy, + }, os.Args[1:]); exitCode != 0 { + os.Exit(exitCode) + } +} diff --git a/chezmoi2/main_test.go b/chezmoi2/main_test.go new file mode 100644 index 000000000000..6f1dfaf2113f --- /dev/null +++ b/chezmoi2/main_test.go @@ -0,0 +1,490 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "io/ioutil" + "os" + "path" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/rogpeppe/go-internal/testscript" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + + "github.com/twpayne/chezmoi/chezmoi2/cmd" + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoi" + "github.com/twpayne/chezmoi/chezmoi2/internal/chezmoitest" +) + +// umask is the umask used in tests. The umask applies to the process and so +// cannot be overridden in individual tests. +const umask = 0o22 + +//nolint:interfacer +func TestMain(m *testing.M) { + chezmoi.SetUmask(umask) + os.Exit(testscript.RunMain(m, map[string]func() int{ + "chezmoi": func() int { + return cmd.Main(cmd.VersionInfo{ + Version: "v2.0.0+test", + Commit: "HEAD", + Date: time.Now().UTC().Format(time.RFC3339), + BuiltBy: "testscript", + }, os.Args[1:]) + }, + })) +} + +//nolint:paralleltest +func TestScript(t *testing.T) { + testscript.Run(t, testscript.Params{ + Dir: filepath.Join("testdata", "scripts"), + Cmds: map[string]func(*testscript.TestScript, bool, []string){ + "chhome": cmdChHome, + "cmpmod": cmdCmpMod, + "edit": cmdEdit, + "mkfile": cmdMkFile, + "mkageconfig": cmdMkAGEConfig, + "mkgitconfig": cmdMkGitConfig, + "mkgpgconfig": cmdMkGPGConfig, + "mkhomedir": cmdMkHomeDir, + "mksourcedir": cmdMkSourceDir, + "rmfinalnewline": cmdRmFinalNewline, + "unix2dos": cmdUNIX2DOS, + }, + Condition: func(cond string) (bool, error) { + switch cond { + case "darwin": + return runtime.GOOS == "darwin", nil + case "githubactionsonwindows": + return chezmoitest.GitHubActionsOnWindows(), nil + case "windows": + return runtime.GOOS == "windows", nil + default: + return false, fmt.Errorf("%s: unknown condition", cond) + } + }, + Setup: setup, + UpdateScripts: os.Getenv("CHEZMOIUPDATESCRIPTS") != "", + }) +} + +// cmdChHome changes the home directory to its argument, creating the directory +// if it does not already exist. It updates the HOME environment variable, and, +// if running on Windows, USERPROFILE too. +func cmdChHome(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! chhome") + } + if len(args) != 1 { + ts.Fatalf("usage: chhome dir") + } + var ( + homeDir = ts.MkAbs(args[0]) + chezmoiConfigDir = path.Join(homeDir, ".config", "chezmoi") + chezmoiSourceDir = path.Join(homeDir, ".local", "share", "chezmoi") + ) + ts.Check(os.MkdirAll(homeDir, 0o777)) + ts.Setenv("HOME", homeDir) + ts.Setenv("CHEZMOICONFIGDIR", chezmoiConfigDir) + ts.Setenv("CHEZMOISOURCEDIR", chezmoiSourceDir) + if runtime.GOOS == "windows" { + ts.Setenv("USERPROFILE", homeDir) + } +} + +// cmdCmpMod compares modes. +func cmdCmpMod(ts *testscript.TestScript, neg bool, args []string) { + if len(args) != 2 { + ts.Fatalf("usage: cmpmod mode path") + } + mode64, err := strconv.ParseUint(args[0], 8, 32) + if err != nil || os.FileMode(mode64).Perm() != os.FileMode(mode64) { + ts.Fatalf("invalid mode: %s", args[0]) + } + if runtime.GOOS == "windows" { + return + } + info, err := os.Stat(args[1]) + if err != nil { + ts.Fatalf("%s: %v", args[1], err) + } + umask := chezmoi.GetUmask() + equal := info.Mode().Perm()&^umask == os.FileMode(mode64)&^umask + if neg && equal { + ts.Fatalf("%s unexpectedly has mode %03o", args[1], info.Mode().Perm()) + } + if !neg && !equal { + ts.Fatalf("%s has mode %03o, expected %03o", args[1], info.Mode().Perm(), os.FileMode(mode64)) + } +} + +// cmdEdit edits all of its arguments by appending "# edited\n" to them. +func cmdEdit(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) + } + } +} + +// cmdMkFile creates empty files. +func cmdMkFile(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! mkfile") + } + perm := os.FileMode(0o666) + if len(args) >= 1 && strings.HasPrefix(args[0], "-perm=") { + permStr := strings.TrimPrefix(args[0], "-perm=") + permUint32, err := strconv.ParseUint(permStr, 8, 32) + if err != nil { + ts.Fatalf("%s: bad permissions", permStr) + } + perm = os.FileMode(permUint32) + args = args[1:] + } + for _, arg := range args { + filename := ts.MkAbs(arg) + _, err := os.Lstat(filename) + switch { + case err == nil: + ts.Fatalf("%s: already exists", arg) + case !os.IsNotExist(err): + ts.Fatalf("%s: %v", arg, err) + } + if err := ioutil.WriteFile(filename, nil, perm); err != nil { + ts.Fatalf("%s: %v", arg, err) + } + } +} + +// cmdMkAGEConfig creates a AGE key and a chezmoi configuration file. +func cmdMkAGEConfig(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unupported: ! mkageconfig") + } + if len(args) > 0 { + ts.Fatalf("usage: mkageconfig") + } + homeDir := ts.Getenv("HOME") + ts.Check(os.MkdirAll(homeDir, 0o777)) + privateKeyFile := filepath.Join(homeDir, "key.txt") + publicKey, _, err := chezmoitest.AGEGenerateKey(ts.MkAbs(privateKeyFile)) + ts.Check(err) + configFile := filepath.Join(homeDir, ".config", "chezmoi", "chezmoi.toml") + ts.Check(os.MkdirAll(filepath.Dir(configFile), 0o777)) + ts.Check(ioutil.WriteFile(configFile, []byte(fmt.Sprintf(chezmoitest.JoinLines( + `encryption = "age"`, + `[age]`, + ` identity = %q`, + ` recipient = %q`, + ), privateKeyFile, publicKey)), 0o666)) +} + +// cmdMkGitConfig makes a .gitconfig file in the home directory. +func cmdMkGitConfig(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! mkgitconfig") + } + if len(args) > 1 { + ts.Fatalf(("usage: mkgitconfig [path]")) + } + path := filepath.Join(ts.Getenv("HOME"), ".gitconfig") + if len(args) > 0 { + path = ts.MkAbs(args[0]) + } + ts.Check(os.MkdirAll(filepath.Dir(path), 0o777)) + ts.Check(ioutil.WriteFile(path, []byte(chezmoitest.JoinLines( + `[core]`, + ` autocrlf = false`, + `[user]`, + ` name = User`, + ` email = user@example.com`, + )), 0o666)) +} + +// cmdMkGPGConfig creates a GPG key and a chezmoi configuration file. +func cmdMkGPGConfig(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unupported: ! mkgpgconfig") + } + if len(args) > 0 { + ts.Fatalf("usage: mkgpgconfig") + } + + // Create a new directory for GPG. We can't use a subdirectory of the + // testscript's working directory because on darwin the absolute path can + // exceed GPG's limit of sockaddr_un.sun_path (107 characters, see man + // unix(7)). The limit exists because GPG creates a UNIX domain socket in + // its home directory and UNIX domain socket paths are limited to + // sockaddr_un.sun_path characters. + gpgHomeDir, err := ioutil.TempDir("", "chezmoi-test-gpg-homedir") + ts.Check(err) + ts.Defer(func() { + os.RemoveAll(gpgHomeDir) + }) + if runtime.GOOS != "windows" { + ts.Check(os.Chmod(gpgHomeDir, 0o700)) + } + + command, err := chezmoitest.GPGCommand() + ts.Check(err) + + key, passphrase, err := chezmoitest.GPGGenerateKey(command, gpgHomeDir) + ts.Check(err) + + configFile := filepath.Join(ts.Getenv("HOME"), ".config", "chezmoi", "chezmoi.toml") + ts.Check(os.MkdirAll(filepath.Dir(configFile), 0o777)) + ts.Check(ioutil.WriteFile(configFile, []byte(fmt.Sprintf(chezmoitest.JoinLines( + `encryption = "gpg"`, + `[gpg]`, + ` args = [`, + ` "--homedir", %q,`, + ` "--no-tty",`, + ` "--passphrase", %q,`, + ` "--pinentry-mode", "loopback",`, + ` ]`, + ` recipient = %q`, + ), gpgHomeDir, passphrase, key)), 0o666)) +} + +// cmdMkHomeDir makes and populates a home directory. +func cmdMkHomeDir(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! mkhomedir") + } + if len(args) > 1 { + ts.Fatalf(("usage: mkhomedir [path]")) + } + path := ts.Getenv("HOME") + if len(args) > 0 { + path = ts.MkAbs(args[0]) + } + workDir := ts.Getenv("WORK") + relPath, err := filepath.Rel(workDir, path) + ts.Check(err) + if err := newBuilder().Build(vfs.NewPathFS(vfs.OSFS, workDir), map[string]interface{}{ + relPath: map[string]interface{}{ + ".dir": map[string]interface{}{ + "file": "# contents of .dir/file\n", + "subdir": map[string]interface{}{ + "file": "# contents of .dir/subdir/file\n", + }, + }, + ".empty": "", + ".executable": &vfst.File{ + Perm: 0o777, + Contents: []byte("# contents of .executable\n"), + }, + ".exists": "# contents of .exists\n", + ".file": "# contents of .file\n", + ".private": &vfst.File{ + Perm: 0o600, + Contents: []byte("# contents of .private\n"), + }, + ".symlink": &vfst.Symlink{Target: ".dir/subdir/file"}, + ".template": "key = value\n", + }, + }); err != nil { + ts.Fatalf("mkhomedir: %v", err) + } +} + +// cmdMkSourceDir makes and populates a source directory. +func cmdMkSourceDir(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! mksourcedir") + } + if len(args) > 1 { + ts.Fatalf("usage: mksourcedir [path]") + } + sourceDir := ts.Getenv("CHEZMOISOURCEDIR") + if len(args) > 0 { + sourceDir = ts.MkAbs(args[0]) + } + workDir := ts.Getenv("WORK") + relPath, err := filepath.Rel(workDir, sourceDir) + ts.Check(err) + err = newBuilder().Build(vfs.NewPathFS(vfs.OSFS, workDir), map[string]interface{}{ + relPath: map[string]interface{}{ + "dot_absent": "", + "dot_dir": map[string]interface{}{ + "file": "# contents of .dir/file\n", + "subdir": map[string]interface{}{ + "file": "# contents of .dir/subdir/file\n", + }, + }, + "empty_dot_empty": "", + "executable_dot_executable": "# contents of .executable\n", + "exists_dot_exists": "# contents of .exists\n", + "dot_file": "# contents of .file\n", + "private_dot_private": "# contents of .private\n", + "symlink_dot_symlink": ".dir/subdir/file\n", + "dot_template.tmpl": chezmoitest.JoinLines( + `key = {{ "value" }}`, + ), + }, + }) + if err != nil { + ts.Fatalf("mksourcedir: %v", err) + } +} + +// cmdRmFinalNewline removes final newlines. +func cmdRmFinalNewline(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! rmfinalnewline") + } + if len(args) < 1 { + ts.Fatalf("usage: rmfinalnewline paths...") + } + for _, arg := range args { + filename := ts.MkAbs(arg) + data, err := ioutil.ReadFile(filename) + if err != nil { + ts.Fatalf("%s: %v", filename, err) + } + if len(data) == 0 || data[len(data)-1] != '\n' { + continue + } + if err := ioutil.WriteFile(filename, data[:len(data)-1], 0o666); err != nil { + ts.Fatalf("%s: %v", filename, err) + } + } +} + +// cmdUNIX2DOS converts files from UNIX line endings to DOS line endings. +func cmdUNIX2DOS(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! unix2dos") + } + if len(args) < 1 { + ts.Fatalf("usage: unix2dos paths...") + } + for _, arg := range args { + filename := ts.MkAbs(arg) + data, err := ioutil.ReadFile(filename) + ts.Check(err) + dosData, err := unix2DOS(data) + ts.Check(err) + if err := ioutil.WriteFile(filename, dosData, 0o666); err != nil { + ts.Fatalf("%s: %v", filename, err) + } + } +} + +func newBuilder() *vfst.Builder { + return vfst.NewBuilder(vfst.BuilderUmask(umask)) +} + +func prependDirToPath(dir, path string) string { + return strings.Join(append([]string{dir}, filepath.SplitList(path)...), string(os.PathListSeparator)) +} + +func setup(env *testscript.Env) error { + var ( + binDir = filepath.Join(env.WorkDir, "bin") + homeDir = filepath.Join(env.WorkDir, "home", "user") + ) + + absHomeDir, err := filepath.Abs(homeDir) + if err != nil { + return err + } + absSlashHomeDir := filepath.ToSlash(absHomeDir) + + var ( + chezmoiConfigDir = path.Join(absSlashHomeDir, ".config", "chezmoi") + chezmoiSourceDir = path.Join(absSlashHomeDir, ".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 := make(map[string]interface{}) + 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": "@for %%x in (%*) do echo # edited >> %%x\r\n", + } + 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(chezmoitest.JoinLines( + `#!/bin/sh`, + ``, + `for name in $*; do`, + ` if [ -d $name ]; then`, + ` touch $name/.edited`, + ` else`, + ` echo "# edited" >> $name`, + ` fi`, + `done`, + )), + }, + // 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(chezmoitest.JoinLines( + `#!/bin/sh`, + ``, + `echo $PWD >> '`+filepath.Join(env.WorkDir, "shell.log")+`'`, + )), + }, + } + } + + return newBuilder().Build(vfs.NewPathFS(vfs.OSFS, env.WorkDir), root) +} + +// unix2DOS returns data with UNIX line endings converted to DOS line endings. +func unix2DOS(data []byte) ([]byte, error) { + sb := strings.Builder{} + s := bufio.NewScanner(bytes.NewReader(data)) + for s.Scan() { + if _, err := sb.Write(s.Bytes()); err != nil { + return nil, err + } + if _, err := sb.WriteString("\r\n"); err != nil { + return nil, err + } + } + if err := s.Err(); err != nil { + return nil, err + } + return []byte(sb.String()), nil +} diff --git a/chezmoi2/testdata/scripts/add.txt b/chezmoi2/testdata/scripts/add.txt new file mode 100644 index 000000000000..d3eb6c912a85 --- /dev/null +++ b/chezmoi2/testdata/scripts/add.txt @@ -0,0 +1,46 @@ +mkhomedir +mksourcedir golden + +# test adding a file in a directory +chezmoi add $HOME${/}.dir/file +cmp $CHEZMOISOURCEDIR/dot_dir/file golden/dot_dir/file + +# test adding a subdirectory +chezmoi add $HOME${/}.dir/subdir +cmp $CHEZMOISOURCEDIR/dot_dir/subdir/file golden/dot_dir/subdir/file + +# test adding an empty file without --empty +chezmoi add $HOME${/}.empty +! exists $CHEZMOISOURCEDIR/dot_empty + +# test adding an empty file with --empty +chezmoi add --empty $HOME${/}.empty +cmp $CHEZMOISOURCEDIR/empty_dot_empty golden/empty_dot_empty + +# test adding an executable file +chezmoi add $HOME${/}.executable +[!windows] cmp $CHEZMOISOURCEDIR/executable_dot_executable golden/executable_dot_executable +[windows] cmp $CHEZMOISOURCEDIR/dot_executable golden/executable_dot_executable + +# test adding that a file should exist +chezmoi add --exists $HOME${/}.exists +cmp $CHEZMOISOURCEDIR/exists_dot_exists golden/exists_dot_exists + +# test adding a private file +chezmoi add $HOME${/}.private +[!windows] cmp $CHEZMOISOURCEDIR/private_dot_private $HOME/.private +[windows] cmp $CHEZMOISOURCEDIR/dot_private $HOME/.private + +# test adding a symlink +chezmoi add $HOME${/}.symlink +cmp $CHEZMOISOURCEDIR/symlink_dot_symlink golden/symlink_dot_symlink + +# test adding a symlink with a separator +symlink $HOME${/}.symlink2 -> .dir${/}subdir${/}file +chezmoi add $HOME${/}.symlink2 +cmp $CHEZMOISOURCEDIR/symlink_dot_symlink2 golden/symlink_dot_symlink + +# test adding a symlink with --follow +symlink $HOME${/}.symlink3 -> .file +chezmoi add --follow $HOME${/}.symlink3 +cmp $CHEZMOISOURCEDIR/dot_symlink3 golden/dot_file diff --git a/chezmoi2/testdata/scripts/addautotemplate.txt b/chezmoi2/testdata/scripts/addautotemplate.txt new file mode 100644 index 000000000000..90085da336f4 --- /dev/null +++ b/chezmoi2/testdata/scripts/addautotemplate.txt @@ -0,0 +1,18 @@ +# test adding a file with --autotemplate +chezmoi add --autotemplate $HOME${/}.template +cmp $CHEZMOISOURCEDIR/dot_template.tmpl golden/dot_template.tmpl + +# test adding a symlink with --autotemplate +symlink $HOME/.symlink -> .target-value +chezmoi add --autotemplate $HOME${/}.symlink +cmp $CHEZMOISOURCEDIR/symlink_dot_symlink.tmpl golden/symlink_dot_symlink.tmpl + +-- golden/dot_template.tmpl -- +key = {{ .variable }} +-- golden/symlink_dot_symlink.tmpl -- +.target-{{ .variable }} +-- home/user/.config/chezmoi/chezmoi.toml -- +[data] + variable = "value" +-- home/user/.template -- +key = value diff --git a/chezmoi2/testdata/scripts/age.txt b/chezmoi2/testdata/scripts/age.txt new file mode 100644 index 000000000000..2a353b2c51a4 --- /dev/null +++ b/chezmoi2/testdata/scripts/age.txt @@ -0,0 +1,22 @@ +[!exec:age] skip 'age not found in $PATH' + +mkhomedir +mkageconfig + +# test that chezmoi add --encrypt encrypts +cp golden/.encrypted $HOME +chezmoi add --encrypt $HOME${/}.encrypted +exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted +! grep plaintext $CHEZMOISOURCEDIR/encrypted_dot_encrypted + +# test that chezmoi apply decrypts +rm $HOME/.encrypted +chezmoi apply --force +cmp golden/.encrypted $HOME/.encrypted + +# test that chezmoi edit --apply transparently decrypts and re-encrypts +chezmoi edit --apply --force $HOME${/}.encrypted +grep '# edited' $HOME/.encrypted + +-- golden/.encrypted -- +plaintext diff --git a/chezmoi2/testdata/scripts/apply.txt b/chezmoi2/testdata/scripts/apply.txt new file mode 100644 index 000000000000..00115c4f4037 --- /dev/null +++ b/chezmoi2/testdata/scripts/apply.txt @@ -0,0 +1,63 @@ +mkhomedir golden +mksourcedir + +# test that chezmoi apply --dry-run does not create any files +chezmoi apply --dry-run --force +! exists $HOME/.absent +! exists $HOME/.dir +! exists $HOME/.dir/file +! exists $HOME/.dir/subdir +! exists $HOME/.dir/subdir/file +! exists $HOME/.empty +! exists $HOME/.executable +! exists $HOME/.exists +! exists $HOME/.file +! exists $HOME/.private +! exists $HOME/.template + +# test that chezmoi apply file creates a single file only +chezmoi apply --force $HOME${/}.file +! exists $HOME/.absent +! exists $HOME/.dir +! exists $HOME/.dir/file +! exists $HOME/.dir/subdir +! exists $HOME/.dir/subdir/file +! exists $HOME/.empty +! exists $HOME/.executable +! exists $HOME/.exists +exists $HOME/.file +! exists $HOME/.private +! exists $HOME/.template + +# test that chezmoi apply dir --recursive=false creates only the directory +chezmoi apply --force --recursive=false $HOME${/}.dir +exists $HOME/.dir +! exists $HOME/.dir/file +! exists $HOME/.dir/subdir +! exists $HOME/.dir/subdir/file + +# test that chezmoi apply dir creates all files in the directory +chezmoi apply --force $HOME${/}.dir +exists $HOME/.dir +exists $HOME/.dir/file +exists $HOME/.dir/subdir +exists $HOME/.dir/subdir/file + +# test that chezmoi apply creates all files +chezmoi apply --force +! exists $HOME/.absent +exists $HOME/.dir +exists $HOME/.dir/file +exists $HOME/.dir/subdir +exists $HOME/.dir/subdir/file +exists $HOME/.empty +exists $HOME/.executable +exists $HOME/.exists +exists $HOME/.file +exists $HOME/.private +exists $HOME/.template + +# test apply after edit +edit $CHEZMOISOURCEDIR/dot_file +chezmoi apply --force +cmp $HOME/.file $CHEZMOISOURCEDIR/dot_file diff --git a/chezmoi2/testdata/scripts/applychmod.txt b/chezmoi2/testdata/scripts/applychmod.txt new file mode 100644 index 000000000000..f6f82a306127 --- /dev/null +++ b/chezmoi2/testdata/scripts/applychmod.txt @@ -0,0 +1,20 @@ +[windows] stop + +mkhomedir golden +mkhomedir +mksourcedir + +# test change file mode +chmod 777 $HOME${/}.file +chezmoi apply --force +cmpmod 666 $HOME/.file + +# test change executable file mode +chmod 666 $HOME/.executable +chezmoi apply --force +cmpmod 777 $HOME/.executable + +# test change directory mode +chmod 700 $HOME/.dir +chezmoi apply --force +cmpmod 777 $HOME/.dir diff --git a/chezmoi2/testdata/scripts/applyexact.txt b/chezmoi2/testdata/scripts/applyexact.txt new file mode 100644 index 000000000000..ea1c71ee5744 --- /dev/null +++ b/chezmoi2/testdata/scripts/applyexact.txt @@ -0,0 +1,20 @@ +# test that chezmoi apply --dry-run does not remove entries from exact directories +chezmoi apply --dry-run --force +exists $HOME/.dir/file1 +exists $HOME/.dir/file2 +exists $HOME/.dir/subdir/file + +# test that chezmoi apply removes entries from exact directories +chezmoi apply --force +exists $HOME/.dir/file1 +! exists $HOME/.dir/file2 +! exists $HOME/.dir/subdir/file + +-- home/user/.dir/file1 -- +# contents of .dir/file1 +-- home/user/.dir/file2 -- +# contents of .dir/file2 +-- home/user/.dir/subdir/file -- +# contents of .dir/subdir/file +-- home/user/.local/share/chezmoi/exact_dot_dir/file1 -- +# contents of .dir/file1 diff --git a/chezmoi2/testdata/scripts/applyignoreencrypted.txt b/chezmoi2/testdata/scripts/applyignoreencrypted.txt new file mode 100644 index 000000000000..5c77d8e05b78 --- /dev/null +++ b/chezmoi2/testdata/scripts/applyignoreencrypted.txt @@ -0,0 +1,22 @@ +[!exec:gpg] skip 'gpg not found in $PATH' +[githubactionsonwindows] skip 'gpg is broken in GitHub Actions on Windows' + +mkhomedir +mkgpgconfig + +# test that chezmoi apply --ignore-encrypted does not apply encrypted files +cp golden/.encrypted $HOME +chezmoi add --encrypt ${HOME}${/}.encrypted +rm $HOME/.encrypted +cp $CHEZMOICONFIGDIR/chezmoi.toml golden/chezmoi.toml +rm $CHEZMOICONFIGDIR/chezmoi.toml +chezmoi apply --force --ignore-encrypted +! exists $HOME/.encrypted + +# test that chezmoi apply applies the encrypted file +cp golden/chezmoi.toml $CHEZMOICONFIGDIR/chezmoi.toml +chezmoi apply --force +cmp golden/.encrypted $HOME/.encrypted + +-- golden/.encrypted -- +plaintext diff --git a/chezmoi2/testdata/scripts/applyremove.txt b/chezmoi2/testdata/scripts/applyremove.txt new file mode 100644 index 000000000000..9af27848d489 --- /dev/null +++ b/chezmoi2/testdata/scripts/applyremove.txt @@ -0,0 +1,27 @@ +# test that chezmoi apply --dry-run --remove does not remove entries +chezmoi apply --dry-run --force --remove +exists $HOME/.dir/file +exists $HOME/.file1 +exists $HOME/.file2 + +# test that chezmoi apply --remove file removes only file +chezmoi apply --force --remove $HOME${/}.file1 +exists $HOME/.dir/file +! exists $HOME/.file1 +exists $HOME/.file2 + +# test that chezmoi apply --remove removes all entries +chezmoi apply --force --remove +! exists $HOME/.dir/file +! exists $HOME/.file1 +! exists $HOME/.file2 + +-- home/user/.dir/file -- +# contents of .dir/file +-- home/user/.file1 -- +# contents of .file1 +-- home/user/.file2 -- +# contents of .file2 +-- home/user/.local/share/chezmoi/.chezmoiremove -- +.dir +.file* diff --git a/chezmoi2/testdata/scripts/applytype.txt b/chezmoi2/testdata/scripts/applytype.txt new file mode 100644 index 000000000000..f9ac8fa5027a --- /dev/null +++ b/chezmoi2/testdata/scripts/applytype.txt @@ -0,0 +1,22 @@ +mkhomedir golden +mkhomedir +mksourcedir + +# test replace directory with file +rm $HOME/.file +mkdir $HOME/.file +chezmoi apply --force +cmp $HOME/.file golden/.file + +# test replace file with directory +rm $HOME/.dir +mkfile $HOME/.dir +chezmoi apply --force +cmp $HOME/.dir/file golden/.dir/file +cmp $HOME/.dir/subdir/file golden/.dir/subdir/file + +# test replace file with symlink +rm $HOME/.symlink +mkfile $HOME/.symlink +chezmoi apply --force +cmp $HOME/.symlink golden/.symlink diff --git a/chezmoi2/testdata/scripts/archivetar.txt b/chezmoi2/testdata/scripts/archivetar.txt new file mode 100644 index 000000000000..8c868db67150 --- /dev/null +++ b/chezmoi2/testdata/scripts/archivetar.txt @@ -0,0 +1,26 @@ +[!exec:tar] skip 'tar not found in $PATH' + +mksourcedir + +[windows] unix2dos golden/archive-tar + +chezmoi archive --output=archive.tar +exec tar -tf archive.tar +cmp stdout golden/archive-tar + +chezmoi archive --gzip --output=archive.tar.gz +exec tar -tzf archive.tar.gz +cmp stdout golden/archive-tar + +-- golden/archive-tar -- +.dir/ +.dir/file +.dir/subdir/ +.dir/subdir/file +.empty +.executable +.exists +.file +.private +.symlink +.template diff --git a/chezmoi2/testdata/scripts/archivezip.txt b/chezmoi2/testdata/scripts/archivezip.txt new file mode 100644 index 000000000000..6a59e4a0dec9 --- /dev/null +++ b/chezmoi2/testdata/scripts/archivezip.txt @@ -0,0 +1,22 @@ +[!exec:unzip] skip 'unzip not found in $PATH' + +mksourcedir + +chezmoi archive --format=zip --output=archive.zip +exec unzip -t archive.zip +cmp stdout golden/archive + +-- golden/archive -- +Archive: archive.zip + testing: .dir OK + testing: .dir/file OK + testing: .dir/subdir OK + testing: .dir/subdir/file OK + testing: .empty OK + testing: .executable OK + testing: .exists OK + testing: .file OK + testing: .private OK + testing: .symlink OK + testing: .template OK +No errors detected in compressed data of archive.zip. diff --git a/chezmoi2/testdata/scripts/autocommit.txt b/chezmoi2/testdata/scripts/autocommit.txt new file mode 100644 index 000000000000..67b9819b755e --- /dev/null +++ b/chezmoi2/testdata/scripts/autocommit.txt @@ -0,0 +1,26 @@ +[!exec:git] 'git not found in $PATH' + +mkgitconfig +mkhomedir golden +mkhomedir + +chezmoi init + +# test that chezmoi add creates and pushes a commit +chezmoi add $HOME${/}.file +exec git --git-dir=$CHEZMOISOURCEDIR/.git show HEAD +stdout 'Add dot_file' + +# test that chezmoi edit creates and pushes a commit +chezmoi edit $HOME${/}.file +exec git --git-dir=$CHEZMOISOURCEDIR/.git show HEAD +stdout 'Update dot_file' + +# test that chezmoi forget creates and pushes a commit +chezmoi forget --force $HOME${/}.file +exec git --git-dir=$CHEZMOISOURCEDIR/.git show HEAD +stdout 'Remove dot_file' + +-- home/user/.config/chezmoi/chezmoi.toml -- +[git] + autoCommit = true diff --git a/chezmoi2/testdata/scripts/autopush.txt b/chezmoi2/testdata/scripts/autopush.txt new file mode 100644 index 000000000000..59c4b174202d --- /dev/null +++ b/chezmoi2/testdata/scripts/autopush.txt @@ -0,0 +1,28 @@ +[!exec:git] 'git not found in $PATH' + +mkgitconfig +mkhomedir golden +mkhomedir + +# create a repo +exec git init --bare $WORK/dotfiles.git +chezmoi init file://$WORK/dotfiles.git + +# test that chezmoi add creates and pushes a commit +chezmoi add $HOME${/}.file +exec git --git-dir=$WORK/dotfiles.git show HEAD +stdout 'Add dot_file' + +# test that chezmoi edit creates and pushes a commit +chezmoi edit $HOME${/}.file +exec git --git-dir=$WORK/dotfiles.git show HEAD +stdout 'Update dot_file' + +# test that chezmoi forget creates and pushes a commit +chezmoi forget --force $HOME${/}.file +exec git --git-dir=$WORK/dotfiles.git show HEAD +stdout 'Remove dot_file' + +-- home/user/.config/chezmoi/chezmoi.toml -- +[git] + autoPush = true diff --git a/chezmoi2/testdata/scripts/bitwarden.txt b/chezmoi2/testdata/scripts/bitwarden.txt new file mode 100644 index 000000000000..84364d8d1adf --- /dev/null +++ b/chezmoi2/testdata/scripts/bitwarden.txt @@ -0,0 +1,92 @@ +[!windows] chmod 755 bin/bw +[windows] unix2dos bin/bw.cmd + +# test bitwarden template function +chezmoi execute-template '{{ (bitwarden "item" "example.com").login.password }}' +stdout password-value + +# test bitwardenFields template function +chezmoi execute-template '{{ (bitwardenFields "item" "example.com").Hidden.value }}' +stdout hidden-value + +-- bin/bw -- +#!/bin/sh + +case "$*" in +"get item example.com") + cat <" +echo "This is free software: you are free to change and redistribute it." +echo "There is NO WARRANTY, to the extent permitted by law." +echo "" +echo "Home: /home/user/.gnupg" +echo "Supported algorithms:" +echo "Pubkey: RSA, ELG, DSA, ECDH, ECDSA, EDDSA" +echo "Cipher: IDEA, 3DES, CAST5, BLOWFISH, AES, AES192, AES256, TWOFISH," +echo " CAMELLIA128, CAMELLIA192, CAMELLIA256" +echo "Hash: SHA1, RIPEMD160, SHA256, SHA384, SHA512, SHA224" +echo "Compression: Uncompressed, ZIP, ZLIB, BZIP2" +-- bin/keepassxc -- +#!/bin/sh + +echo "2.5.4" +-- bin/lpass -- +#!/bin/sh + +echo "LastPass CLI v1.3.3.GIT" +-- bin/op -- +#!/bin/sh + +echo "1.3.0" +-- bin/pass -- +#!/bin/sh + +echo "============================================" +echo "= pass: the standard unix password manager =" +echo "= =" +echo "= v1.7.3 =" +echo "= =" +echo "= Jason A. Donenfeld =" +echo "= Jason@zx2c4.com =" +echo "= =" +echo "= http://www.passwordstore.org/ =" +echo "============================================" +-- bin/secret -- +#!/bin/sh +-- bin/vault -- +#!/bin/sh + +echo "Vault v1.5.5 ('f5d1ddb3750e7c28e25036e1ef26a4c02379fc01+CHANGES')" +-- bin/vimdiff -- +#!/bin/sh +-- home/user/.config/chezmoi/chezmoi.toml -- +[secret] + command = "secret" +[keepassxc] + command = "keepassxc" diff --git a/chezmoi2/testdata/scripts/dumpjson.txt b/chezmoi2/testdata/scripts/dumpjson.txt new file mode 100644 index 000000000000..cb770f0bcea6 --- /dev/null +++ b/chezmoi2/testdata/scripts/dumpjson.txt @@ -0,0 +1,125 @@ +mksourcedir + +chezmoi dump --format=json +cmp stdout golden/dump.json + +chezmoi dump --format=json $HOME${/}.file +cmp stdout golden/dump-file.json + +chezmoi dump --format=json $HOME${/}.dir +cmp stdout golden/dump-dir.json + +chezmoi dump --format=json --recursive=false $HOME${/}.dir +cmp stdout golden/dump-dir-non-recursive.json + +! chezmoi dump $HOME${/}.inputrc +stderr 'not in source state' + +-- golden/dump.json -- +{ + ".dir": { + "type": "dir", + "name": ".dir", + "perm": 511 + }, + ".dir/file": { + "type": "file", + "name": ".dir/file", + "contents": "# contents of .dir/file\n", + "perm": 438 + }, + ".dir/subdir": { + "type": "dir", + "name": ".dir/subdir", + "perm": 511 + }, + ".dir/subdir/file": { + "type": "file", + "name": ".dir/subdir/file", + "contents": "# contents of .dir/subdir/file\n", + "perm": 438 + }, + ".empty": { + "type": "file", + "name": ".empty", + "contents": "", + "perm": 438 + }, + ".executable": { + "type": "file", + "name": ".executable", + "contents": "# contents of .executable\n", + "perm": 511 + }, + ".exists": { + "type": "file", + "name": ".exists", + "contents": "# contents of .exists\n", + "perm": 438 + }, + ".file": { + "type": "file", + "name": ".file", + "contents": "# contents of .file\n", + "perm": 438 + }, + ".private": { + "type": "file", + "name": ".private", + "contents": "# contents of .private\n", + "perm": 384 + }, + ".symlink": { + "type": "symlink", + "name": ".symlink", + "linkname": ".dir/subdir/file" + }, + ".template": { + "type": "file", + "name": ".template", + "contents": "key = value\n", + "perm": 438 + } +} +-- golden/dump-file.json -- +{ + ".file": { + "type": "file", + "name": ".file", + "contents": "# contents of .file\n", + "perm": 438 + } +} +-- golden/dump-dir.json -- +{ + ".dir": { + "type": "dir", + "name": ".dir", + "perm": 511 + }, + ".dir/file": { + "type": "file", + "name": ".dir/file", + "contents": "# contents of .dir/file\n", + "perm": 438 + }, + ".dir/subdir": { + "type": "dir", + "name": ".dir/subdir", + "perm": 511 + }, + ".dir/subdir/file": { + "type": "file", + "name": ".dir/subdir/file", + "contents": "# contents of .dir/subdir/file\n", + "perm": 438 + } +} +-- golden/dump-dir-non-recursive.json -- +{ + ".dir": { + "type": "dir", + "name": ".dir", + "perm": 511 + } +} diff --git a/chezmoi2/testdata/scripts/dumpyaml.txt b/chezmoi2/testdata/scripts/dumpyaml.txt new file mode 100644 index 000000000000..afb64046bb79 --- /dev/null +++ b/chezmoi2/testdata/scripts/dumpyaml.txt @@ -0,0 +1,65 @@ +mksourcedir + +chezmoi dump --format=yaml +cmp stdout golden/dump.yaml + +-- golden/dump.yaml -- +.dir: + type: dir + name: .dir + perm: 511 +.dir/file: + type: file + name: .dir/file + contents: | + # contents of .dir/file + perm: 438 +.dir/subdir: + type: dir + name: .dir/subdir + perm: 511 +.dir/subdir/file: + type: file + name: .dir/subdir/file + contents: | + # contents of .dir/subdir/file + perm: 438 +.empty: + type: file + name: .empty + contents: "" + perm: 438 +.executable: + type: file + name: .executable + contents: | + # contents of .executable + perm: 511 +.exists: + type: file + name: .exists + contents: | + # contents of .exists + perm: 438 +.file: + type: file + name: .file + contents: | + # contents of .file + perm: 438 +.private: + type: file + name: .private + contents: | + # contents of .private + perm: 384 +.symlink: + type: symlink + name: .symlink + linkname: .dir/subdir/file +.template: + type: file + name: .template + contents: | + key = value + perm: 438 diff --git a/chezmoi2/testdata/scripts/edgecases.txt b/chezmoi2/testdata/scripts/edgecases.txt new file mode 100644 index 000000000000..54008132a907 --- /dev/null +++ b/chezmoi2/testdata/scripts/edgecases.txt @@ -0,0 +1,13 @@ +mkhomedir + +# test that chezmoi add --dry-run does not modify anything +chezmoi add --dry-run $HOME${/}.file +! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb +! exists $CHEZMOISOURCEDIR/dot_file + +# test that chezmoi add updates the persistent state +chezmoi add $HOME${/}.file +exists $CHEZMOICONFIGDIR/chezmoistate.boltdb +exists $CHEZMOISOURCEDIR/dot_file +chezmoi state dump +stdout 634a4dd193c7b3b926d2e08026aa81a416fd41cec52854863b974af422495663 # sha256sum of "# contents of .file\n" diff --git a/chezmoi2/testdata/scripts/edit.txt b/chezmoi2/testdata/scripts/edit.txt new file mode 100644 index 000000000000..1903dd91c5d8 --- /dev/null +++ b/chezmoi2/testdata/scripts/edit.txt @@ -0,0 +1,33 @@ +mkhomedir +mksourcedir + +chezmoi edit $HOME${/}.file +grep -count=1 '# edited' $CHEZMOISOURCEDIR/dot_file +! grep '# edited' $HOME/.file + +chezmoi edit --apply --force $HOME${/}.file +grep -count=2 '# edited' $CHEZMOISOURCEDIR/dot_file +grep -count=2 '# edited' $HOME/.file + +chezmoi edit $HOME${/}.symlink +grep -count=1 '# edited' $CHEZMOISOURCEDIR/symlink_dot_symlink + +chezmoi edit -v $HOME${/}script +grep -count=1 '# edited' $CHEZMOISOURCEDIR/run_script + +chezmoi edit $HOME${/}.file $HOME${/}.symlink +grep -count=3 '# edited' $CHEZMOISOURCEDIR/dot_file +grep -count=2 '# edited' $CHEZMOISOURCEDIR/symlink_dot_symlink + +[windows] stop 'remaining tests need update to editor.cmd' # FIXME + +chezmoi edit +exists $CHEZMOISOURCEDIR/.edited + +[windows] stop 'remaining tests use file modes' + +chezmoi edit $HOME${/}.dir +exists $CHEZMOISOURCEDIR/dot_dir/.edited + +-- home/user/.local/share/chezmoi/run_script -- +#!/bin/sh diff --git a/chezmoi2/testdata/scripts/editconfig.txt b/chezmoi2/testdata/scripts/editconfig.txt new file mode 100644 index 000000000000..f038a976c743 --- /dev/null +++ b/chezmoi2/testdata/scripts/editconfig.txt @@ -0,0 +1,25 @@ +# test that edit-config creates a config file if needed +chezmoi edit-config +grep -count=1 '# edited' $CHEZMOICONFIGDIR/chezmoi.toml + +# test that edit-config edits an existing config file +chezmoi edit-config +grep -count=2 '# edited' $CHEZMOICONFIGDIR/chezmoi.toml + +# test that edit-config edits an existing YAML config file +chhome home2/user +chezmoi edit-config +grep -count=1 '# edited' $CHEZMOICONFIGDIR/chezmoi.yaml + +# test that edit-config reports a warning if the config is no longer valid +chhome home3/user +! stderr warning +chezmoi edit-config +stderr warning +grep -count=1 '# edited' $CHEZMOICONFIGDIR/chezmoi.json + +-- home2/user/.config/chezmoi/chezmoi.yaml -- +data: + email: "user@example.com" +-- home3/user/.config/chezmoi/chezmoi.json -- +{"data":{"email":"user@example.com"}} diff --git a/chezmoi2/testdata/scripts/errors.txt b/chezmoi2/testdata/scripts/errors.txt new file mode 100644 index 000000000000..94da73c42d30 --- /dev/null +++ b/chezmoi2/testdata/scripts/errors.txt @@ -0,0 +1,39 @@ +mksourcedir + +# test duplicate source state entry detection +cp $CHEZMOISOURCEDIR/dot_file $CHEZMOISOURCEDIR/empty_dot_file +! chezmoi verify +stderr 'duplicate source state entries' + +# test invalid config +chhome home2/user +! chezmoi verify +stderr 'invalid config' + +# test source directory is not a directory +chhome home3/user +! chezmoi verify +stderr 'not a directory' + +# test that chezmoi checks .chezmoiversion +chhome home4/user +! chezmoi verify +stderr 'source state requires version' + +# test duplicate script detection +chhome home5/user +! chezmoi verify +stderr 'duplicate source state entries' + +# FIXME add more tests + +-- home2/user/.config/chezmoi/chezmoi.json -- +{ +-- home3/user/.local/share/chezmoi -- +# contents of .local/share/chezmoi +-- home4/user/.local/share/chezmoi/.chezmoiversion -- +3.0.0 +-- home5/user/.local/share/chezmoi/run_install_packages -- +# contents of install_packages +-- home5/user/.local/share/chezmoi/run_once_install_packages -- +# contents of install_packages diff --git a/chezmoi2/testdata/scripts/executetemplate.txt b/chezmoi2/testdata/scripts/executetemplate.txt new file mode 100644 index 000000000000..c7eab180f947 --- /dev/null +++ b/chezmoi2/testdata/scripts/executetemplate.txt @@ -0,0 +1,80 @@ +# test reading args +chezmoi execute-template '{{ "arg-template" }}' +stdout arg-template + +# test reading from stdin +stdin golden/stdin.tmpl +chezmoi execute-template +stdout stdin-template + +# test partial templates work +chezmoi execute-template '{{ template "partial" }}' +stdout 'hello world' + +# FIXME merge the following tests into a single test + +chezmoi execute-template '{{ .last.config }}' +stdout 'chezmoi\.toml' + +# test that template data are read from .chezmoidata.json +chezmoi execute-template '{{ .last.json }}' +stdout '\.chezmoidata\.json' + +# test that template data are read from .chezmoidata.toml +chezmoi execute-template '{{ .last.toml }}' +stdout '\.chezmoidata\.toml' + +# test that template data are read from .chezmoidata.yaml +chezmoi execute-template '{{ .last.yaml }}' +stdout '\.chezmoidata\.yaml' + +# test that the last .chezmoidata. file read wins +chezmoi execute-template '{{ .last.format }}' +stdout '\.chezmoidata\.yaml' + +# test that the config file wins over .chezmoidata. +chezmoi execute-template '{{ .last.global }}' +stdout chezmoi.toml + +# test --init --promptBool +chezmoi execute-template --init --promptBool value=yes '{{ promptBool "value" }}' +stdout true +! chezmoi execute-template --promptBool value=error +stderr 'invalid syntax' + +# test --init --promptInt +chezmoi execute-template --init --promptInt value=1 '{{ promptInt "value" }}' +stdout 1 +! chezmoi execute-template --promptInt value=error +stderr 'invalid syntax' + +# test --init --promptString +chezmoi execute-template --init --promptString email=user@example.com '{{ promptString "email" }}' +stdout 'user@example.com' + +-- golden/stdin.tmpl -- +{{ "stdin-template" }} +-- home/user/.config/chezmoi/chezmoi.toml -- +[data.last] + config = "chezmoi.toml" + global = "chezmoi.toml" +-- home/user/.local/share/chezmoi/.chezmoidata.json -- +{ + "last": { + "format": ".chezmoidata.json", + "global": ".chezmoidata.json", + "json": ".chezmoidata.json" + } +} +-- home/user/.local/share/chezmoi/.chezmoidata.toml -- +[last] + format = ".chezmoidata.toml" + global = ".chezmoidata.toml" + toml = ".chezmoidata.toml" +-- home/user/.local/share/chezmoi/.chezmoidata.yaml -- +last: + format: ".chezmoidata.yaml" + global: ".chezmoidata.yaml" + yaml: ".chezmoidata.yaml" +-- home/user/.local/share/chezmoi/.chezmoitemplates/partial -- +{{ cat "hello" "world" }} diff --git a/chezmoi2/testdata/scripts/forget.txt b/chezmoi2/testdata/scripts/forget.txt new file mode 100644 index 000000000000..1e368f9d92dc --- /dev/null +++ b/chezmoi2/testdata/scripts/forget.txt @@ -0,0 +1,11 @@ +mksourcedir + +# test that chezmoi forget file forgets a file +exists $CHEZMOISOURCEDIR/dot_file +chezmoi forget --force $HOME${/}.file +! exists $CHEZMOISOURCEDIR/dot_file + +# test that chezmoi forget dir forgets a dir +exists $CHEZMOISOURCEDIR/dot_dir +chezmoi forget --force $HOME${/}.dir +! exists $CHEZMOISOURCEDIR/dot_dir diff --git a/chezmoi2/testdata/scripts/git.txt b/chezmoi2/testdata/scripts/git.txt new file mode 100644 index 000000000000..47bc86045977 --- /dev/null +++ b/chezmoi2/testdata/scripts/git.txt @@ -0,0 +1,18 @@ +[!windows] chmod 755 bin/git +[windows] unix2dos bin/git.cmd + +chezmoi git hello +exists $CHEZMOISOURCEDIR +stdout hello + +-- bin/git -- +#!/bin/sh + +echo $* +-- bin/git.cmd -- +@echo off +setlocal +set out=%* +set out=%out:\=% +echo %out% +endlocal diff --git a/chezmoi2/testdata/scripts/gopass.txt b/chezmoi2/testdata/scripts/gopass.txt new file mode 100644 index 000000000000..35057f0874ee --- /dev/null +++ b/chezmoi2/testdata/scripts/gopass.txt @@ -0,0 +1,32 @@ +[!windows] chmod 755 bin/gopass +[windows] unix2dos bin/gopass.cmd + +# test gopass template function +chezmoi execute-template '{{ gopass "misc/example.com" }}' +stdout examplepassword + +-- bin/gopass -- +#!/bin/sh + +case "$*" in +"--version") + echo "gopass 1.10.1 go1.15 linux amd64" + ;; +"show --password misc/example.com") + echo "examplepassword" + ;; +*) + echo "gopass: invalid command: $*" + exit 1 +esac +-- bin/gopass.cmd -- +@echo off +IF "%*" == "--version" ( + echo "gopass 1.10.1 go1.15 windows amd64" +) ELSE IF "%*" == "show --password misc/example.com" ( + echo | set /p=examplepassword + exit /b 0 +) ELSE ( + echo gopass: invalid command: %* + exit /b 1 +) diff --git a/chezmoi2/testdata/scripts/gpg.txt b/chezmoi2/testdata/scripts/gpg.txt new file mode 100644 index 000000000000..878cc4450a86 --- /dev/null +++ b/chezmoi2/testdata/scripts/gpg.txt @@ -0,0 +1,23 @@ +[!exec:gpg] skip 'gpg not found in $PATH' +[githubactionsonwindows] skip 'gpg is broken in GitHub Actions on Windows' + +mkhomedir +mkgpgconfig + +# test that chezmoi add --encrypt encrypts +cp golden/.encrypted $HOME +chezmoi add --encrypt ${HOME}${/}.encrypted +exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted +! grep plaintext $CHEZMOISOURCEDIR/encrypted_dot_encrypted + +# test that chezmoi apply decrypts +rm $HOME/.encrypted +chezmoi apply --force +cmp golden/.encrypted $HOME/.encrypted + +# test that chezmoi edit --apply transparently decrypts and re-encrypts +chezmoi edit --apply --force $HOME${/}.encrypted +grep '# edited' $HOME/.encrypted + +-- golden/.encrypted -- +plaintext diff --git a/chezmoi2/testdata/scripts/help.txt b/chezmoi2/testdata/scripts/help.txt new file mode 100644 index 000000000000..249c15596b60 --- /dev/null +++ b/chezmoi2/testdata/scripts/help.txt @@ -0,0 +1,5 @@ +chezmoi help +stdout 'Manage your dotfiles across multiple diverse machines, securely' + +chezmoi help add +stdout 'Add \*targets\* to the source state\.' diff --git a/chezmoi2/testdata/scripts/ignore.txt b/chezmoi2/testdata/scripts/ignore.txt new file mode 100644 index 000000000000..ba0a44905724 --- /dev/null +++ b/chezmoi2/testdata/scripts/ignore.txt @@ -0,0 +1,17 @@ +mksourcedir + +# test that chezmoi apply does not write ignored files +! exists $HOME/.file +chezmoi apply --force +exists $HOME/.file +! exists $HOME/README.md +! exists $HOME/.dir + +-- home/user/.local/share/chezmoi/.chezmoiignore -- +README.md +.dir +{{ if false }} +.file +{{ end }} +-- home/user/.local/share/chezmoi/README.md -- +# contents of README.md diff --git a/chezmoi2/testdata/scripts/import.txt b/chezmoi2/testdata/scripts/import.txt new file mode 100644 index 000000000000..728ac4a8a392 --- /dev/null +++ b/chezmoi2/testdata/scripts/import.txt @@ -0,0 +1,18 @@ +[!exec:tar] skip 'tar not found in $PATH' + +mkhomedir + +# test that chezmoi import imports and archive +exec tar czf archive.tar.gz archive +chezmoi import --strip-components=1 archive.tar.gz +cmp $CHEZMOISOURCEDIR/dot_dir/dot_file golden/dot_dir/dot_file +cmp $CHEZMOISOURCEDIR/dot_dir/dot_symlink golden/dot_dir/symlink_dot_symlink + +-- archive/.dir/.file -- +# contents of .dir/.file +-- archive/.dir/.symlink -- +.file +-- golden/dot_dir/dot_file -- +# contents of .dir/.file +-- golden/dot_dir/symlink_dot_symlink -- +.file diff --git a/chezmoi2/testdata/scripts/init.txt b/chezmoi2/testdata/scripts/init.txt new file mode 100644 index 000000000000..760413ee2865 --- /dev/null +++ b/chezmoi2/testdata/scripts/init.txt @@ -0,0 +1,58 @@ +[!exec:git] stop + +mkgitconfig +mkhomedir golden +mkhomedir + +# test that chezmoi init creates a git repo +chezmoi init +exists $CHEZMOISOURCEDIR/.git + +# create a commit +cp golden/.file $CHEZMOISOURCEDIR/dot_file +chezmoi git add dot_file +chezmoi git commit -- --message 'Add dot_file' + +# test that chezmoi init fetches git repo but does not apply +chhome home2/user +mkgitconfig +chezmoi init file://$WORK/home/user/.local/share/chezmoi +exists $CHEZMOISOURCEDIR/.git +! exists $HOME/.file + +# test that chezmoi init --apply fetches a git repo and runs chezmoi apply +chhome home3/user +mkgitconfig +chezmoi init --apply --force file://$WORK/home/user/.local/share/chezmoi +exists $CHEZMOISOURCEDIR/.git +cmp $HOME/.file golden/.file + +# test that chezmoi init --apply --depth 1 --force --purge clones, applies, and purges +chhome home4/user +mkgitconfig +exists $CHEZMOICONFIGDIR +! exists $CHEZMOISOURCEDIR +chezmoi init --apply --depth 1 --force --purge file://$WORK/home/user/.local/share/chezmoi +cmp $HOME/.file golden/.file +! exists $CHEZMOICONFIGDIR +! exists $CHEZMOISOURCEDIR + +# test that chezmoi init does not clone the repo if it is already checked out but does create the config file +chhome home5/user +mkgitconfig +chezmoi init --source=$HOME/dotfiles file://$WORK/nonexistentrepo +exists $CHEZMOICONFIGDIR/chezmoi.toml + +# test chezmoi init --one-shot +chhome home6/user +mkgitconfig +chezmoi init --one-shot file://$WORK/home/user/.local/share/chezmoi +cmp $HOME/.file golden/.file +! exists $CHEZMOICONFIGDIR +! exists $CHEZMOISOURCEDIR + +-- home4/user/.config/chezmoi/chezmoi.toml -- +-- home5/user/dotfiles/.git/.keep -- +-- home5/user/dotfiles/.chezmoi.toml.tmpl -- +[data] + email = "user@home.org" diff --git a/chezmoi2/testdata/scripts/issue-796.txt b/chezmoi2/testdata/scripts/issue-796.txt new file mode 100644 index 000000000000..cd3f52d015dc --- /dev/null +++ b/chezmoi2/testdata/scripts/issue-796.txt @@ -0,0 +1,6 @@ +mkhomedir +mksourcedir + +symlink $CHEZMOISOURCEDIR/dot_file2 -> dot_file +chezmoi apply --force +cmp $HOME/.file2 $HOME/.file diff --git a/chezmoi2/testdata/scripts/keep-going.txt b/chezmoi2/testdata/scripts/keep-going.txt new file mode 100644 index 000000000000..725f75a0dd7d --- /dev/null +++ b/chezmoi2/testdata/scripts/keep-going.txt @@ -0,0 +1,37 @@ +mkhomedir + +# test that chezmoi diff without --keep-going fails when there is an error +! chezmoi diff + +# test that chezmoi diff with --keep-going succeeds, even if there is an error +chezmoi diff --keep-going +stdout 1ok +! stdout 2error +stdout 3ok + +# test that chezmoi apply without --keep-going fails but still writes the first file +! chezmoi apply --force +cmp $HOME/1ok golden/1ok +! exists $HOME/2error +! exists $HOME/3ok + +# test that chezmoi apply with --keep-going writes all files that it can without errors +chezmoi apply --force --keep-going +cmp $HOME/1ok golden/1ok +! exists $HOME/2error +cmp $HOME/3ok golden/3ok + +# FIXME add chezmoi init tests + +# FIXME add chezmoi update tests + +-- golden/1ok -- +first +-- golden/3ok -- +last +-- home/user/.local/share/chezmoi/1ok -- +first +-- home/user/.local/share/chezmoi/2error.tmpl -- +{{ +-- home/user/.local/share/chezmoi/3ok -- +last diff --git a/chezmoi2/testdata/scripts/keepassxc.txt b/chezmoi2/testdata/scripts/keepassxc.txt new file mode 100644 index 000000000000..b50a7f14070f --- /dev/null +++ b/chezmoi2/testdata/scripts/keepassxc.txt @@ -0,0 +1,58 @@ +[!windows] chmod 755 bin/keepass-test +[windows] unix2dos bin/keepass-test.cmd + +# test keepassxcAttribute template function +stdin $HOME/input +chezmoi execute-template '{{ keepassxcAttribute "example.com" "host-name" }}' +stdout example.com + +# test keepassxc template function and that password is only requested once +stdin $HOME/input +chezmoi execute-template '{{ (keepassxc "example.com").UserName }}/{{ (keepassxc "example.com").Password }}' +stdout examplelogin/examplepassword + +-- bin/keepass-test -- +#!/bin/sh + +case "$*" in +"--version") + echo "2.5.4" + ;; +"show --show-protected secrets.kdbx example.com") + cat < .script +symlink home/user/.local/share/chezmoi/run_first_99-first -> .script +symlink home/user/.local/share/chezmoi/run_00 -> .script +symlink home/user/.local/share/chezmoi/run_99 -> .script +symlink home/user/.local/share/chezmoi/run_last_00-last -> .script +symlink home/user/.local/share/chezmoi/run_last_99-last -> .script +chezmoi apply --force +cmp stdout golden/apply + +-- golden/apply -- +00-first +99-first +00 +99 +00-last +99-last +-- home/user/.local/share/chezmoi/.script -- +#!/bin/sh + +basename=$(basename $0) +echo ${basename##*.} diff --git a/chezmoi2/testdata/scripts/scriptsubdir_unix.txt b/chezmoi2/testdata/scripts/scriptsubdir_unix.txt new file mode 100644 index 000000000000..cbb31420979f --- /dev/null +++ b/chezmoi2/testdata/scripts/scriptsubdir_unix.txt @@ -0,0 +1,37 @@ +[windows] skip 'UNIX only' + +# test that scripts in subdirectories are run in the subdirectory +chezmoi apply --force +cmpenv stdout golden/apply + +chezmoi dump +cmp stdout golden/dump + +[!exec:tar] stop 'tar not found in $PATH' + +chezmoi archive --gzip --output=archive.tar.gz +exec tar -tzf archive.tar.gz +cmp stdout golden/archive + +-- golden/apply -- +$HOME/dir +-- golden/archive -- +dir/ +dir/script +-- golden/dump -- +{ + "dir": { + "type": "dir", + "name": "dir", + "perm": 511 + }, + "dir/script": { + "type": "script", + "name": "dir/script", + "contents": "#!/bin/sh\n\necho $PWD\n" + } +} +-- home/user/.local/share/chezmoi/dir/run_script -- +#!/bin/sh + +echo $PWD diff --git a/chezmoi2/testdata/scripts/secret.txt b/chezmoi2/testdata/scripts/secret.txt new file mode 100644 index 000000000000..57dfd8e569a3 --- /dev/null +++ b/chezmoi2/testdata/scripts/secret.txt @@ -0,0 +1,25 @@ +[!windows] chmod 755 bin/secret +[windows] unix2dos bin/secret.cmd + +# test secret template function +chezmoi execute-template '{{ secret "password" }}' +stdout password + +# test secretJSON template function +chezmoi execute-template '{{ (secretJSON "{\"password\":\"secret\"}").password }}' +stdout secret + +-- bin/secret -- +#!/bin/sh + +echo "$*" +-- bin/secret.cmd -- +@echo off +setlocal +set out=%* +set out=%out:\=% +echo %out% +endlocal +-- home/user/.config/chezmoi/chezmoi.toml -- +[secret] + command = "secret" diff --git a/chezmoi2/testdata/scripts/sourcedir.txt b/chezmoi2/testdata/scripts/sourcedir.txt new file mode 100644 index 000000000000..11424e8cfbed --- /dev/null +++ b/chezmoi2/testdata/scripts/sourcedir.txt @@ -0,0 +1,5 @@ +chezmoi execute-template '{{ .chezmoi.sourceDir }}' +stdout '/tmp/user' + +-- home/user/.config/chezmoi/chezmoi.toml -- +sourceDir = "/tmp/user" diff --git a/chezmoi2/testdata/scripts/sourcepath.txt b/chezmoi2/testdata/scripts/sourcepath.txt new file mode 100644 index 000000000000..c12938ff1462 --- /dev/null +++ b/chezmoi2/testdata/scripts/sourcepath.txt @@ -0,0 +1,18 @@ +mksourcedir + +chezmoi source-path +cmpenv stdout golden/source-path + +chezmoi source-path $HOME${/}.file +cmpenv stdout golden/source-path-file + +! chezmoi source-path $HOME${/}.newfile +stderr 'not in source state' + +! chezmoi source-path $WORK${/}etc${/}passwd +stderr 'not in' + +-- golden/source-path -- +$CHEZMOISOURCEDIR +-- golden/source-path-file -- +$CHEZMOISOURCEDIR/dot_file diff --git a/chezmoi2/testdata/scripts/state_unix.txt b/chezmoi2/testdata/scripts/state_unix.txt new file mode 100644 index 000000000000..5d0b4322f8e5 --- /dev/null +++ b/chezmoi2/testdata/scripts/state_unix.txt @@ -0,0 +1,25 @@ +[windows] skip 'UNIX only' + +# test that the persistent state is only created on demand +chezmoi state dump --format=yaml +cmp stdout golden/dump +! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb + +# test that chezmoi apply updates the persistent state +chezmoi apply --force +exists $CHEZMOICONFIGDIR/chezmoistate.boltdb + +# test that the persistent state records that script was run +chezmoi state dump --format=yaml +stdout a8076d3d28d21e02012b20eaf7dbf75409a6277134439025f282e368e3305abf: +stdout runAt: + +# test that chezmoi state reset removes the persistent state +chezmoi --force state reset +! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb + +-- golden/dump -- +entryState: {} +scriptState: {} +-- home/user/.local/share/chezmoi/run_once_script -- +#!/bin/sh diff --git a/chezmoi2/testdata/scripts/status.txt b/chezmoi2/testdata/scripts/status.txt new file mode 100644 index 000000000000..18be6008a702 --- /dev/null +++ b/chezmoi2/testdata/scripts/status.txt @@ -0,0 +1,54 @@ +# FIXME add more tests + +mkhomedir golden +mksourcedir + +# test that chezmoi status lists all files to be added +chezmoi status +cmp stdout golden/status + +# test that chezmoi status omits applied files +chezmoi apply --force $HOME${/}.file +chezmoi status +cmp stdout golden/status-except-dot-file + +# test that chezmoi status is empty after apply +chezmoi apply --force +chezmoi status +! stdout . + +# test that chezmoi status identifies files that have been modified in the destination directory +edit $HOME/.file +chezmoi status +cmp stdout golden/status-modified-file + +# test that chezmoi status does not emit status for equivalent modifications +edit $CHEZMOISOURCEDIR/dot_file +chezmoi status +! stdout . + +-- golden/status -- + A .dir + A .dir/file + A .dir/subdir + A .dir/subdir/file + A .empty + A .executable + A .exists + A .file + A .private + A .symlink + A .template +-- golden/status-except-dot-file -- + A .dir + A .dir/file + A .dir/subdir + A .dir/subdir/file + A .empty + A .executable + A .exists + A .private + A .symlink + A .template +-- golden/status-modified-file -- +MM .file diff --git a/chezmoi2/testdata/scripts/templatefuncs.txt b/chezmoi2/testdata/scripts/templatefuncs.txt new file mode 100644 index 000000000000..7a0553baf078 --- /dev/null +++ b/chezmoi2/testdata/scripts/templatefuncs.txt @@ -0,0 +1,11 @@ +[darwin] chezmoi execute-template '{{ index ioreg "IOKitBuildVersion" }}' +[darwin] stdout 'Darwin Kernel Version' + +chezmoi execute-template '{{ joinPath "a" "b" }}' +stdout a${/}b + +chezmoi execute-template '{{ lookPath "go" }}' +stdout go${exe} + +chezmoi execute-template '{{ (stat ".").isDir }}' +stdout true diff --git a/chezmoi2/testdata/scripts/tilde.txt b/chezmoi2/testdata/scripts/tilde.txt new file mode 100644 index 000000000000..5eddc179f7c3 --- /dev/null +++ b/chezmoi2/testdata/scripts/tilde.txt @@ -0,0 +1,8 @@ +mkhomedir +mksourcedir + +chezmoi source-path ~/.file +stdout $CHEZMOISOURCEDIR/dot_file + +! chezmoi source-path ~ +stderr 'not in' diff --git a/chezmoi2/testdata/scripts/unmanaged.txt b/chezmoi2/testdata/scripts/unmanaged.txt new file mode 100644 index 000000000000..dde48b07f1fe --- /dev/null +++ b/chezmoi2/testdata/scripts/unmanaged.txt @@ -0,0 +1,23 @@ +mkhomedir +mksourcedir + +chezmoi unmanaged +cmp stdout golden/unmanaged + +rm $CHEZMOISOURCEDIR/dot_dir +chezmoi unmanaged +cmp stdout golden/unmanaged-dir + +rm $CHEZMOISOURCEDIR/dot_file +chezmoi unmanaged +cmp stdout golden/unmanaged-dir-file + +-- golden/unmanaged -- +.local +-- golden/unmanaged-dir -- +.dir +.local +-- golden/unmanaged-dir-file -- +.dir +.file +.local diff --git a/chezmoi2/testdata/scripts/update.txt b/chezmoi2/testdata/scripts/update.txt new file mode 100644 index 000000000000..3b22c90f6b6c --- /dev/null +++ b/chezmoi2/testdata/scripts/update.txt @@ -0,0 +1,46 @@ +[!exec:git] 'git not found in $PATH' + +mkgitconfig +mkhomedir golden +mkhomedir + +exec git init --bare $WORK/dotfiles.git + +chezmoi init file://$WORK/dotfiles.git + +# create a commit +chezmoi add $HOME${/}.file +chezmoi git add dot_file +chezmoi git commit -- --message 'Add dot_file' +chezmoi git push + +chhome home2/user +mkgitconfig +chezmoi init --apply --force file://$WORK/dotfiles.git +cmp $HOME/.file golden/.file + +# create and push a new commit +chhome home/user +edit $CHEZMOISOURCEDIR/dot_file +chezmoi git -- add dot_file +chezmoi git -- commit -m 'Update dot_file' +chezmoi git -- push + +# test chezmoi update +chhome home2/user +chezmoi update +grep -count=1 '# edited' $HOME/.file + +# create and push a new commit +chhome home/user +edit $CHEZMOISOURCEDIR/dot_file +chezmoi git -- add dot_file +chezmoi git -- commit -m 'Update dot_file' +chezmoi git -- push + +# test chezmoi update --apply=false +chhome home2/user +chezmoi update --apply=false +grep -count=1 '# edited' $HOME/.file +chezmoi apply --force +grep -count=2 '# edited' $HOME/.file diff --git a/chezmoi2/testdata/scripts/vault.txt b/chezmoi2/testdata/scripts/vault.txt new file mode 100644 index 000000000000..5650a6b98887 --- /dev/null +++ b/chezmoi2/testdata/scripts/vault.txt @@ -0,0 +1,62 @@ +[!windows] chmod 755 bin/vault +[windows] unix2dos bin/vault.cmd + +# test vault template function +chezmoi execute-template '{{ (vault "secret/examplesecret").data.data.password }}' +stdout examplepassword + +-- bin/vault -- +#!/bin/sh + +case "$*" in +"kv get -format=json secret/examplesecret") +cat < [args]" + exit 127 +esac +-- bin/vault.cmd -- +@echo off +IF "%*" == "kv get -format=json secret/examplesecret" ( + echo.{ + echo. "request_id": "d90311b6-2f3f-768e-656c-ce768e773b09", + echo. "lease_id": "", + echo. "lease_duration": 0, + echo. "renewable": false, + echo. "data": { + echo. "data": { + echo. "password": "examplepassword" + echo. }, + echo. "metadata": { + echo. "created_time": "2021-01-11T21:48:46.961974384Z", + echo. "deletion_time": "", + echo. "destroyed": false, + echo. "version": 1 + echo. } + echo. }, + echo. "warnings": null + echo.} +) ELSE ( + echo "Usage: vault [args]" + exit /b 127 +) diff --git a/chezmoi2/testdata/scripts/verify.txt b/chezmoi2/testdata/scripts/verify.txt new file mode 100644 index 000000000000..baac57fcbde2 --- /dev/null +++ b/chezmoi2/testdata/scripts/verify.txt @@ -0,0 +1,48 @@ +mkhomedir golden +mkhomedir +mksourcedir + +# test that chezmoi verify succeeds +chezmoi verify + +# test that chezmoi verify fails when a file is added to the source state +cp golden/dot_newfile $CHEZMOISOURCEDIR/dot_newfile +! chezmoi verify +chezmoi forget --force $HOME${/}.newfile +chezmoi verify + +# test that chezmoi verify fails when a file is edited +edit $HOME/.file +! chezmoi verify +chezmoi apply --force $HOME${/}.file +chezmoi verify + +# test that chezmoi verify fails when a file is removed from the destination directory +rm $HOME/.file +! chezmoi verify +chezmoi apply --force $HOME${/}.file +chezmoi verify + +# test that chezmoi verify fails when a directory is removed from the destination directory +rm $HOME/.dir +! chezmoi verify +mkdir $HOME/.dir +chezmoi apply --force $HOME${/}.dir +chezmoi verify + +[windows] stop 'remaining tests use file modes' + +# test that chezmoi verify fails when a file's permissions are changed +chmod 777 $HOME/.file +! chezmoi verify +chezmoi apply --force $HOME${/}.file +chezmoi verify + +# test that chezmoi verify fails when a dir's permissions are changed +chmod 700 $HOME/.dir +! chezmoi verify +chezmoi apply --force $HOME${/}.dir +chezmoi verify + +-- golden/dot_newfile -- +# contents of .newfile diff --git a/chezmoi2/testdata/scripts/version.txt b/chezmoi2/testdata/scripts/version.txt new file mode 100644 index 000000000000..2201056b6bde --- /dev/null +++ b/chezmoi2/testdata/scripts/version.txt @@ -0,0 +1,2 @@ +chezmoi --version +stdout 'chezmoi version 2\.0\.0' diff --git a/go.mod b/go.mod index 8cda281a2e06..7a81feab2cf3 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,12 @@ go 1.14 require ( github.com/Masterminds/sprig/v3 v3.2.0 + github.com/Microsoft/go-winio v0.4.16 // indirect github.com/alecthomas/chroma v0.8.2 // indirect github.com/alecthomas/colour v0.1.0 // indirect github.com/alecthomas/repr v0.0.0-20201120212035-bb82daffcca2 // indirect github.com/bmatcuk/doublestar/v3 v3.0.0 - github.com/charmbracelet/glamour v0.2.0 + github.com/charmbracelet/glamour v0.2.1-0.20201227140546-4292a2106d74 github.com/coreos/go-semver v0.3.0 github.com/danieljoos/wincred v1.1.0 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect @@ -21,13 +22,15 @@ require ( github.com/google/uuid v1.1.2 // indirect github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect github.com/huandu/xstrings v1.3.2 // indirect + github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect github.com/magiconair/properties v1.8.4 // indirect github.com/mitchellh/mapstructure v1.4.0 // indirect github.com/mitchellh/reflectwalk v1.0.1 // indirect - github.com/muesli/termenv v0.7.1 // indirect + github.com/muesli/combinator v0.3.0 github.com/pelletier/go-toml v1.8.1 github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7 github.com/rogpeppe/go-internal v1.6.2 + github.com/rs/zerolog v1.20.0 github.com/sergi/go-diff v1.1.0 github.com/smartystreets/assertions v1.2.0 // indirect github.com/spf13/afero v1.5.1 // indirect @@ -40,6 +43,7 @@ require ( github.com/twpayne/go-vfs v1.7.2 github.com/twpayne/go-vfsafero v1.0.0 github.com/twpayne/go-xdg/v3 v3.1.0 + github.com/xanzy/ssh-agent v0.3.0 // indirect github.com/zalando/go-keyring v0.1.0 go.etcd.io/bbolt v1.3.5 go.uber.org/multierr v1.6.0 @@ -53,6 +57,6 @@ require ( gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/yaml.v2 v2.4.0 - gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect + gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 howett.net/plist v0.0.0-20201203080718-1454fab16a06 ) diff --git a/go.sum b/go.sum index f3857ebf6b69..454eb5d6f46c 100644 --- a/go.sum +++ b/go.sum @@ -41,11 +41,16 @@ github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030I github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/sprig/v3 v3.2.0 h1:P1ekkbuU73Ui/wS0nK1HOM37hh4xdfZo485UPf8rc+Y= github.com/Masterminds/sprig/v3 v3.2.0/go.mod h1:tWhwTbUTndesPNeF0C900vKoq283u6zp4APT9vaF3SI= +github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk= +github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/chroma v0.7.3/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM= +github.com/alecthomas/chroma v0.8.1/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM= github.com/alecthomas/chroma v0.8.2 h1:x3zkuE2lUk/RIekyAJ3XRqSCP4zwWDfcw/YJCuCAACg= github.com/alecthomas/chroma v0.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM= github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= @@ -59,11 +64,15 @@ github.com/alecthomas/repr v0.0.0-20201120212035-bb82daffcca2 h1:G5TeG64Ox4OWq2Y github.com/alecthomas/repr v0.0.0-20201120212035-bb82daffcca2/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= @@ -72,8 +81,10 @@ github.com/bmatcuk/doublestar/v3 v3.0.0 h1:TQtVPlDnAYwcrVNB2JiGuMc++H5qzWZd9PhkN github.com/bmatcuk/doublestar/v3 v3.0.0/go.mod h1:6PcTVMw80pCY1RVuoqu3V++99uQB3vsSYKPTd8AWA0k= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/charmbracelet/glamour v0.2.0 h1:mTgaiNiumpqTZp3qVM6DH9UB0NlbY17wejoMf1kM8Pg= -github.com/charmbracelet/glamour v0.2.0/go.mod h1:UA27Kwj3QHialP74iU6C+Gpc8Y7IOAKupeKMLLBURWM= +github.com/charmbracelet/glamour v0.2.1-0.20201227140546-4292a2106d74 h1:2RW5V98yvMn5V0utSoWNxr2pjH8MRyQhaCd0HwVjOYE= +github.com/charmbracelet/glamour v0.2.1-0.20201227140546-4292a2106d74/go.mod h1:WIVFX8Y2VIK1Y/1qtXYL/Vvzqlcbo3VgVop9i2piPkE= +github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU= +github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -109,17 +120,20 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM= github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-git-fixtures/v4 v4.0.2-0.20200613231340-f56387b50c12 h1:PbKy9zOy4aAKrJ5pibIRpVO2BXnK1Tlcg+caKI7Ox5M= github.com/go-git/go-git-fixtures/v4 v4.0.2-0.20200613231340-f56387b50c12/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= github.com/go-git/go-git/v5 v5.2.0 h1:YPBLG/3UK1we1ohRkncLjaXWLW+HKp5QNM/jTli2JgI= github.com/go-git/go-git/v5 v5.2.0/go.mod h1:kh02eMX+wdqqxgNMEyq8YgwlIOsDOa9homkUq1PoTMs= @@ -183,8 +197,6 @@ github.com/google/go-github/v33 v33.0.0 h1:qAf9yP0qc54ufQxzwv+u9H0tiVOnPJxo0lI/J github.com/google/go-github/v33 v33.0.0/go.mod h1:GMdDnVZY/2TsWgp/lkYnpSAh6TrzhANBBwm6k6TTEXg= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4= -github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -208,6 +220,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0= github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= @@ -255,6 +269,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -285,8 +301,8 @@ github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= -github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg= +github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= @@ -308,12 +324,12 @@ github.com/mitchellh/reflectwalk v1.0.1 h1:FVzMWA5RllMAKIdUSC8mdWo3XtwoecrH79BY7 github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/muesli/reflow v0.1.0 h1:oQdpLfO56lr5pgLvqD0TcjW85rDjSYSBVdiG1Ch1ddM= -github.com/muesli/reflow v0.1.0/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I= -github.com/muesli/termenv v0.6.0 h1:zxvzTBmo4ZcxhNGGWeMz+Tttm51eF5bmPjfy4MCRYlk= -github.com/muesli/termenv v0.6.0/go.mod h1:SohX91w6swWA4AYU+QmPx+aSgXhWO0juiyID9UZmbpA= -github.com/muesli/termenv v0.7.1 h1:f/0LJlWtufB7yG24zpnxtbC1MmGMbUFPRF5DqAnfSQg= -github.com/muesli/termenv v0.7.1/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc= +github.com/muesli/combinator v0.3.0 h1:SZDuRzzwmVPLkbOzbhGzBTwd5+Y6aFN4UusOW2azrNA= +github.com/muesli/combinator v0.3.0/go.mod h1:ttPegJX0DPQaGDtJKMInIP6Vfp5pN8RX7QntFCcpy18= +github.com/muesli/reflow v0.2.0 h1:2o0UBJPHHH4fa2GCXU4Rg4DwOtWPMekCeyc5EWbAQp0= +github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8= +github.com/muesli/termenv v0.7.4 h1:/pBqvU5CpkY53tU0vVn+xgs2ZTX63aH5nY+SSps5Xa8= +github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -329,6 +345,7 @@ github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7 h1:+/+DxvQaYifJ+grD4klzrS github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -348,6 +365,9 @@ github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6So github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.2 h1:aIihoIOHCiLZHxyoNQ+ABL4NKhFTgKLBdMLyEAh98m0= github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs= +github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= @@ -360,6 +380,7 @@ github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFR github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= @@ -418,12 +439,17 @@ github.com/twpayne/go-xdg/v3 v3.1.0 h1:AxX5ZLJIzqYHJh+4uGxWT97ySh1ND1bJLjqMxdYF+ github.com/twpayne/go-xdg/v3 v3.1.0/go.mod h1:z6/LkoG2gtuzrsxEqPRoEjccS5Q35GK+lguVP0K3L9o= github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= +github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= +github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.0 h1:WOOcyaJPlzb8fZ8TloxFe8QZkhOOJx87leDa9MIT9dc= -github.com/yuin/goldmark v1.2.0/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I= +github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= +github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= github.com/zalando/go-keyring v0.1.0 h1:ffq972Aoa4iHNzBlUHgK5Y+k8+r/8GvcGd80/OFZb/k= github.com/zalando/go-keyring v0.1.0/go.mod h1:RaxNwUITJaHVdQ0VC7pELPZ3tOWn13nr0gZMZEhpVU0= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= @@ -434,9 +460,11 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= @@ -554,6 +582,7 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -611,6 +640,7 @@ golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/internal/cmd/lint-whitespace/main.go b/internal/cmd/lint-whitespace/main.go index ae1c45d44575..6cacc203c3f6 100644 --- a/internal/cmd/lint-whitespace/main.go +++ b/internal/cmd/lint-whitespace/main.go @@ -19,6 +19,7 @@ var ( regexp.MustCompile(`\.svg\z`), regexp.MustCompile(`\A\.devcontainer/library-scripts\z`), regexp.MustCompile(`\A\.git\z`), + regexp.MustCompile(`\Achezmoi2/completions/chezmoi\.ps1\z`), regexp.MustCompile(`\Acompletions/chezmoi\.ps1\z`), regexp.MustCompile(`\Achezmoi\.io/public\z`), regexp.MustCompile(`\Achezmoi\.io/themes/book\z`),