diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 5c3988c6ffe..00000000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -ARG VARIANT=dev-1.17 -FROM mcr.microsoft.com/vscode/devcontainers/go:${VARIANT} - -# [Optional] Uncomment this section to install additional OS packages. -RUN export DEBIAN_FRONTEND=noninteractive \ - && apt-get update \ - && apt-get install --yes --no-install-recommends acl musl-tools \ - && rm -rf /var/lib/apt/lists/* - -# [Optional] Uncomment the next line to use go get to install anything else you need -RUN go get -x mvdan.cc/gofumpt - -RUN curl -fsLS https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$(go env GOPATH)/bin" - -RUN curl -fsLS https://install.goreleaser.com/github.com/goreleaser/goreleaser.sh | sh -s -- -b "$(go env GOPATH)/bin" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 0a12d11d1a5..00000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "chezmoi", - "build": { - "dockerfile": "Dockerfile", - }, - "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], - - // Set *default* container specific settings.json values on container create. - "settings": { - "go.toolsManagement.checkForUpdates": "off", - "go.useLanguageServer": true, - "go.gopath": "/go", - "go.goroot": "/usr/local/go", - "go.toolsGopath": "/go/bin", - "markdown.extension.toc.updateOnSave": false - }, - - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "EditorConfig.EditorConfig", - "golang.Go", - "yzhang.markdown-all-in-one" - ], - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "go mod download", - - // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "vscode" -} diff --git a/.ecrc b/.ecrc new file mode 100644 index 00000000000..936853682ef --- /dev/null +++ b/.ecrc @@ -0,0 +1,5 @@ +{ + "Exclude": [ + "^completions/" + ] +} diff --git a/.editorconfig b/.editorconfig index 56a7b9bc31d..914c2ea683f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,13 +1,25 @@ root = true [*] -charset = "utf-8" -end_of_line = "lf" +charset = utf-8 +end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [*.ps1] -charset = "utf-8" -end_of_line = "crlf" +charset = utf-8 +end_of_line = crlf insert_final_newline = true trim_trailing_whitespace = true + +[*.py] +indent_size = 4 +indent_style = space + +[*.sh] +indent_size = 1 +indent_style = tab + +[{*.yaml,*.yml}] +indent_size = 2 +indent_style = space diff --git a/.gitattributes b/.gitattributes index d9e289a0fe5..80b6d1dd1d9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,6 @@ * -text # Make GitHub language breakdown more accurate, see https://github.com/github/linguist -*.gen.go linguist-generated +assets/scripts/install.sh linguist-generated +assets/scripts/install-local-bin.sh linguist-generated completions/* linguist-generated diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 2b4a5fccdaf..4f84d10fe14 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -1,3 +1,3 @@ # Code of Conduct -Please read the [Go Community Code of Conduct](https://golang.org/conduct). +[Contributor Covenant Code Of Conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000000..2b8ffd0c7c2 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +See https://chezmoi.io/developer-guide/contributing-changes/. diff --git a/.github/ISSUE_TEMPLATE/01_support_request.md b/.github/ISSUE_TEMPLATE/01_support_request.md index 87a88b2cfdd..58d771f8428 100644 --- a/.github/ISSUE_TEMPLATE/01_support_request.md +++ b/.github/ISSUE_TEMPLATE/01_support_request.md @@ -4,7 +4,6 @@ about: Get help with using chezmoi title: '' labels: support assignees: '' - --- ## What exactly are you trying to do? @@ -17,23 +16,26 @@ Describe what you have tried so far. ## Where else have you checked for solutions? -* [ ] I have read [chezmoi's how-to guide](https://github.com/twpayne/chezmoi/blob/master/docs/HOWTO.md), and not found the answer. -* [ ] I have read [chezmoi's FAQ](https://github.com/twpayne/chezmoi/blob/master/docs/FAQ.md), and not found the answer. -* [ ] I have searched [chezmoi's reference guide](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md), and not found the answer. +* [ ] I have read [chezmoi's user guide](https://chezmoi.io/user-guide/command-overview/), and not found the answer. +* [ ] I have searched [chezmoi's reference guide](https://chezmoi.io/reference/), and not found the answer. * [ ] Other, please give details. ## Output of any commands you've tried with `--verbose` flag ```console -$ chezmoi --verbose +$ chezmoi --verbose $COMMAND ``` ## Output of `chezmoi doctor` +
+ ```console $ chezmoi doctor ``` +
+ ## Additional context Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/02_feature_request.md b/.github/ISSUE_TEMPLATE/02_feature_request.md index e7308ec8eb7..05a5e2bb4e5 100644 --- a/.github/ISSUE_TEMPLATE/02_feature_request.md +++ b/.github/ISSUE_TEMPLATE/02_feature_request.md @@ -4,7 +4,6 @@ about: Request a new feature title: '' labels: enhancement assignees: '' - --- ## Is your feature request related to a problem? Please describe. diff --git a/.github/ISSUE_TEMPLATE/03_bug_report.md b/.github/ISSUE_TEMPLATE/03_bug_report.md index b82c8f7cfba..6b2900c5d47 100644 --- a/.github/ISSUE_TEMPLATE/03_bug_report.md +++ b/.github/ISSUE_TEMPLATE/03_bug_report.md @@ -3,7 +3,6 @@ name: Bug report about: Report a bug title: '' assignees: '' - --- ## Describe the bug @@ -21,15 +20,19 @@ A clear and concise description of what you expected to happen. ## Output of command with the `--verbose` flag ```console -$ chezmoi --verbose +$ chezmoi --verbose $COMMAND ``` ## Output of `chezmoi doctor` +
+ ```console $ chezmoi doctor ``` +
+ ## Additional context Add any other context about the problem here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index aa0cc50d85f..3f57cabf0a2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,6 +3,6 @@ Thanks for contributing! Please make sure that you have followed the contributing guide: -https://github.com/twpayne/chezmoi/blob/master/docs/CONTRIBUTING.md +https://chezmoi.io/developer-guide/contributing-changes/ --> diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000000..6d97848705b --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security + +See https://chezmoi.io/developer-guide/security/. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 18a3a5e770a..25ce53fc22a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,16 +5,42 @@ updates: schedule: interval: monthly labels: - - enhancement - - package-ecosystem: gitsubmodule + - enhancement + + groups: + go-dev: + dependency-type: development + patterns: + - '*' + update-types: [minor, patch] + + go-prod: + dependency-type: production + patterns: + - '*' + update-types: [minor, patch] + + - package-ecosystem: github-actions directory: / schedule: interval: monthly labels: - - enhancement - - package-ecosystem: github-actions - directory: / + - enhancement + + groups: + actions: + patterns: + - '*' + update-types: [minor, patch] + + - package-ecosystem: pip + directory: /assets schedule: interval: monthly labels: - - enhancement + - enhancement + groups: + python: + patterns: + - '*' + update-types: [minor, patch] diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 00000000000..05f43847f96 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,26 @@ +name: govulncheck +on: + pull_request: + branches: + - master + push: + branches: + - master + tags: + - v* + schedule: + - cron: 2 2 * * * +jobs: + govulncheck: + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - name: go-version + id: go-version + run: | + echo go-version="$(awk '/GO_VERSION:/ { print $2 }' .github/workflows/main.yml | tr -d \')" >> "${GITHUB_OUTPUT}" + - uses: golang/govulncheck-action@dd0578b371c987f96d1185abb54344b44352bd58 + with: + go-version-input: ${{ steps.go-version.outputs.go-version }} diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml new file mode 100644 index 00000000000..18dd41f5978 --- /dev/null +++ b/.github/workflows/installer.yml @@ -0,0 +1,106 @@ +name: installer +on: + pull_request: + branches: + - master + push: + branches: + - master +env: + SHA: ${{ github.event_name == 'push' && github.sha || github.event.pull_request.head.sha }} +jobs: + changes: + runs-on: ubuntu-22.04 + outputs: + sh: ${{ steps.filter.outputs.sh }} + ps1: ${{ steps.filter.outputs.ps1 }} + permissions: + contents: read + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - id: filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 + with: + filters: | + shared: &shared + - '.github/workflows/installer.yml' + sh: + - *shared + - 'assets/scripts/install*.sh' + - 'internal/cmds/generate-install.sh/install.sh.tmpl' + ps1: + - *shared + - 'assets/scripts/install.ps1' + misspell: + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - uses: reviewdog/action-misspell@ef8b22c1cca06c8d306fc6be302c3dab0f6ca12f + with: + locale: US + ignore: ackward + test-install-sh: + if: ${{ needs.changes.outputs.sh == 'true' }} + strategy: + matrix: + os: + - macos-12 + - ubuntu-20.04 + #- windows-2022 # fails with "debug http_download_curl received HTTP status 000" + needs: changes + runs-on: ${{ matrix.os }} + env: + BINARY: ${{ matrix.os == 'windows-2022' && 'bin/chezmoi.exe' || 'bin/chezmoi' }} + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - name: test-${{ matrix.os }}-local + shell: bash + run: | + rm -f ${{ env.BINARY }} + sh assets/scripts/install.sh -d + ${{ env.BINARY }} --version + - name: test-${{ matrix.os }}-url + shell: bash + run: | + rm -f ${{ env.BINARY }} + sh -c "$(curl -fsLS https://raw.githubusercontent.com/twpayne/chezmoi/${{ env.SHA }}/assets/scripts/install.sh)" -- -d + ${{ env.BINARY }} --version + test-install-ps1: + if: ${{ needs.changes.outputs.ps1 == 'true' }} + strategy: + matrix: + os: [macos-12, ubuntu-20.04, windows-2022] + needs: changes + runs-on: ${{ matrix.os }} + env: + BINARY: ${{ matrix.os == 'windows-2022' && 'bin/chezmoi.exe' || 'bin/chezmoi' }} + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - name: test-${{ matrix.os }}-local-pwsh + shell: pwsh + run: | + if (Test-Path -Path ${{ env.BINARY }}) { Remove-Item -Force ${{ env.BINARY }} } + assets/scripts/install.ps1 -d + ${{ env.BINARY }} --version + - name: test-${{ matrix.os }}-local-powershell + if: matrix.os == 'windows-2022' + shell: powershell + run: | + if (Test-Path -Path ${{ env.BINARY }}) { Remove-Item -Force ${{ env.BINARY }} } + assets/scripts/install.ps1 -d + ${{ env.BINARY }} --version + - name: test-${{ matrix.os }}-url-pwsh + shell: pwsh + run: | + if (Test-Path -Path ${{ env.BINARY }}) { Remove-Item -Force ${{ env.BINARY }} } + iex "&{$(irm 'https://raw.githubusercontent.com/twpayne/chezmoi/${{ env.SHA }}/assets/scripts/install.ps1')} -d" + ${{ env.BINARY }} --version + - name: test-${{ matrix.os }}-url-powershell + if: matrix.os == 'windows-2022' + shell: powershell + run: | + if (Test-Path -Path ${{ env.BINARY }}) { Remove-Item -Force ${{ env.BINARY }} } + iex "&{$(irm 'https://raw.githubusercontent.com/twpayne/chezmoi/${{ env.SHA }}/assets/scripts/install.ps1')} -d" + ${{ env.BINARY }} --version diff --git a/.github/workflows/lock-threads.yml b/.github/workflows/lock-threads.yml new file mode 100644 index 00000000000..966072b68fd --- /dev/null +++ b/.github/workflows/lock-threads.yml @@ -0,0 +1,26 @@ +name: lock-threads + +on: + schedule: + - cron: 17 2 * * * + workflow_dispatch: {} + +permissions: + issues: write + pull-requests: write + +concurrency: + group: lock-threads + +jobs: + action: + runs-on: ubuntu-22.04 + steps: + - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 + with: + process-only: 'issues, prs' + issue-lock-reason: resolved + issue-inactive-days: 7 + pr-lock-reason: resolved + pr-inactive-days: 7 + log-output: true diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b2b14b4fc89..711bc914139 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,465 +8,471 @@ on: - master tags: - v* + schedule: + - cron: 32 2 * * * env: - AGE_VERSION: 1.0.0 - GO_VERSION: 1.17.3 - GOLANGCI_LINT_VERSION: 1.43.0 + ACTIONLINT_VERSION: 1.7.1 # https://github.com/rhysd/actionlint/releases + AGE_VERSION: 1.2.0 # https://github.com/FiloSottile/age/releases + CHOCOLATEY_VERSION: 2.2.2 # https://github.com/chocolatey/choco/releases + EDITORCONFIG_CHECKER_VERSION: 3.0.3 # https://github.com/editorconfig-checker/editorconfig-checker/releases + FIND_TYPOS_VERSION: 0.0.3 # https://github.com/twpayne/find-typos/tags + GO_VERSION: 1.22.5 # https://go.dev/doc/devel/release + GOFUMPT_VERSION: 0.6.0 # https://github.com/mvdan/gofumpt/releases + GOLANGCI_LINT_VERSION: 1.59.1 # https://github.com/golangci/golangci-lint/releases + GOLINES_VERSION: 0.12.2 # https://github.com/segmentio/golines/releases + GORELEASER_VERSION: 2.1.0 # https://github.com/goreleaser/goreleaser/releases + GOVERSIONINFO_VERSION: 1.4.0 # https://github.com/josephspurrier/goversioninfo/releases + RAGE_VERSION: 0.10.0 # https://github.com/str4d/rage/releases jobs: changes: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: code: ${{ steps.filter.outputs.code }} + permissions: + contents: read steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Check changes - id: filter - uses: dorny/paths-filter@v2 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - id: filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 with: filters: | code: - '**/*.go' - - '.github/workflows/**' + - '.github/workflows/main.yml' + - '.goreleaser.yaml' - 'Makefile' + - 'assets/**/*.tmpl' - 'assets/docker/**' + - 'assets/scripts/*.py' + - 'assets/scripts/generate-commit.go' + - 'assets/scripts/stow-to-chezmoi.sh' - 'assets/vagrant/**' - - 'internal/**' + - 'completions/**' + - 'go.*' + - 'internal/**/!(install.sh.tmpl)' codeql: needs: changes if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 permissions: security-events: write steps: - - name: Checkout repo - uses: actions/checkout@v2 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 with: fetch-depth: 1 - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + - uses: github/codeql-action/init@b611370bb5703a7efb587f9d136a52ea24c5c38c with: languages: go - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 - test-archlinux: - needs: changes - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: ubuntu-20.04 - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Test - run: | - ( cd assets/docker && ./test.sh archlinux ) - test-debian-i386: - needs: changes - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: macos-10.15 - env: - VAGRANT_BOX: debian11-i386 + - uses: github/codeql-action/analyze@b611370bb5703a7efb587f9d136a52ea24c5c38c + misspell: + runs-on: ubuntu-22.04 + permissions: + contents: read steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Cache Vagrant Boxes - uses: actions/cache@v2 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - uses: reviewdog/action-misspell@ef8b22c1cca06c8d306fc6be302c3dab0f6ca12f with: - path: ~/.vagrant.d - key: ${{ runner.os }}-vagrant-${{ env.VAGRANT_BOX }}-${{ hashFiles('assets/vagrant/debian11-i386.Vagrantfile') }} - restore-keys: | - ${{ runner.os }}-vagrant-${{ env.VAGRANT_BOX }}- - - name: Test - run: | - ( cd assets/vagrant && ./test.sh debian11-i386 ) - test-fedora: + locale: US + ignore: ackward,importas + test-alpine: needs: changes if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 + permissions: + contents: read steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Test + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - name: test + env: + CHEZMOI_GITHUB_TOKEN: ${{ secrets.CHEZMOI_GITHUB_TOKEN }} run: | - ( cd assets/docker && ./test.sh fedora ) - test-freebsd: + ( cd assets/docker && ./test.sh alpine ) + test-archlinux: needs: changes if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: macos-10.15 - env: - VAGRANT_BOX: freebsd13 + runs-on: ubuntu-22.04 + permissions: + contents: read steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Cache Vagrant Boxes - uses: actions/cache@v2 - with: - path: ~/.vagrant.d - key: ${{ runner.os }}-vagrant-${{ env.VAGRANT_BOX }}-${{ hashFiles('assets/vagrant/freebsd13.Vagrantfile') }} - restore-keys: | - ${{ runner.os }}-vagrant-${{ env.VAGRANT_BOX }}- - - name: Test + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - name: test + env: + CHEZMOI_GITHUB_TOKEN: ${{ secrets.CHEZMOI_GITHUB_TOKEN }} run: | - ( cd assets/vagrant && ./test.sh freebsd13 ) + ( cd assets/docker && ./test.sh archlinux ) test-macos: needs: changes if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: macos-11 + runs-on: macos-12 + permissions: + contents: read steps: - - name: Set up Go - uses: actions/setup-go@v2 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 with: go-version: ${{ env.GO_VERSION }} - - name: Checkout - uses: actions/checkout@v2 - - name: Cache Go modules - uses: actions/cache@v2 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - name: Build + - name: build run: | go build ./... - - name: Run + - name: run run: | go run . --version - - name: Install age + - name: install-age run: | - cd $(mktemp -d) - curl -fsSL https://github.com/FiloSottile/age/releases/download/v${AGE_VERSION}/age-v${AGE_VERSION}-darwin-amd64.tar.gz | tar xzf - - sudo install -m 755 age/age /usr/local/bin - sudo install -m 755 age/age-keygen /usr/local/bin - - name: Test + brew install age + age --version + - name: install-rage + run: | + brew tap str4d.xyz/rage https://str4d.xyz/rage + brew install rage + rage --version + - name: install-keepassxc + run: | + brew install keepassxc + keepassxc-cli --version + - name: test + env: + CHEZMOI_GITHUB_TOKEN: ${{ secrets.CHEZMOI_GITHUB_TOKEN }} run: | go test -race ./... - test-openbsd: + test-oldstable-go: needs: changes if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: macos-10.15 - env: - VAGRANT_BOX: openbsd6 + runs-on: ubuntu-22.04 + permissions: + contents: read steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Cache Vagrant Boxes - uses: actions/cache@v2 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 with: - path: ~/.vagrant.d - key: ${{ runner.os }}-vagrant-${{ env.VAGRANT_BOX }}-${{ hashFiles('assets/vagrant/openbsd6.Vagrantfile') }} - restore-keys: | - ${{ runner.os }}-vagrant-${{ env.VAGRANT_BOX }}- - - name: Test + go-version: oldstable + - name: build run: | - ( cd assets/vagrant && ./test.sh openbsd6 ) - test-openindiana: - needs: changes - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: macos-10.15 - env: - VAGRANT_BOX: openindiana - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Cache Vagrant Boxes - uses: actions/cache@v2 - with: - path: ~/.vagrant.d - key: ${{ runner.os }}-vagrant-${{ env.VAGRANT_BOX }}-${{ hashFiles('assets/vagrant/openindiana.Vagrantfile') }} - restore-keys: | - ${{ runner.os }}-vagrant-${{ env.VAGRANT_BOX }}- - - name: Test + go build ./... + - name: run run: | - ( cd assets/vagrant && ./test.sh openindiana ) - test-ubuntu: - needs: changes - runs-on: ubuntu-18.04 - steps: - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: ${{ env.GO_VERSION }} - - name: Install age - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + go run . --version + - name: install-age run: | - cd $(mktemp -d) - curl -fsSL https://github.com/FiloSottile/age/releases/download/v${AGE_VERSION}/age-v${AGE_VERSION}-linux-amd64.tar.gz | tar xzf - + cd "$(mktemp -d)" + curl -fsSL "https://github.com/FiloSottile/age/releases/download/v${AGE_VERSION}/age-v${AGE_VERSION}-linux-amd64.tar.gz" | tar xzf - sudo install -m 755 age/age /usr/local/bin sudo install -m 755 age/age-keygen /usr/local/bin - - name: Checkout - uses: actions/checkout@v2 + - name: install-rage + run: | + cd "$(mktemp -d)" + curl -fsSL "https://github.com/str4d/rage/releases/download/v${RAGE_VERSION}/rage-v${RAGE_VERSION}-x86_64-linux.tar.gz" | tar xzf - + sudo install -m 755 rage/rage /usr/local/bin + sudo install -m 755 rage/rage-keygen /usr/local/bin + - name: test + env: + CHEZMOI_GITHUB_TOKEN: ${{ secrets.CHEZMOI_GITHUB_TOKEN }} + run: | + go test ./... + test-release: + needs: changes + runs-on: ubuntu-20.04 # use older Ubuntu for older glibc + permissions: + contents: read + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 with: fetch-depth: 0 - - name: Cache Go modules - uses: actions/cache@v2 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - name: Build - run: | - go build ./... - - name: Run - run: | - go run . --version - - name: Test (umask 022) - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - run: | - go test -ldflags="-X github.com/twpayne/chezmoi/internal/chezmoitest.umaskStr=0o022" -race ./... - - name: Test (umask 002) - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - run: | - go test -ldflags="-X github.com/twpayne/chezmoi/internal/chezmoitest.umaskStr=0o002" -race ./... - - name: Install release dependencies + go-version: ${{ env.GO_VERSION }} + - name: install-release-dependencies if: github.event_name == 'push' || needs.changes.outputs.code == 'true' run: | - sudo apt-get update - sudo apt-get -yq --no-install-suggests --no-install-recommends install musl-tools snapcraft - - name: Build release + sudo apt-get --quiet update + sudo apt-get --no-install-suggests --no-install-recommends --quiet --yes install musl-tools snapcraft + mkdir -p /opt/chocolatey + wget -q -O - "https://github.com/chocolatey/choco/releases/download/${CHOCOLATEY_VERSION}/chocolatey.v${CHOCOLATEY_VERSION}.tar.gz" | tar -xz -C "/opt/chocolatey" + echo '#!/bin/bash' >> /usr/local/bin/choco + echo 'mono /opt/chocolatey/choco.exe $@' >> /usr/local/bin/choco + chmod +x /usr/local/bin/choco + - name: create-syso + run: | + make create-syso + - name: build-release if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - uses: goreleaser/goreleaser-action@v2 + uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 with: - version: latest - args: release --skip-publish --snapshot - - name: Test release + version: ${{ env.GORELEASER_VERSION }} + args: release --skip=sign --snapshot --timeout=1h + - name: upload-artifact-chezmoi-darwin-amd64 if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - run: | - # verify that version information is embedded correctly - file ./dist/chezmoi-cgo-glibc_linux_amd64/chezmoi | tee /dev/stderr | grep -q "dynamically linked" - file ./dist/chezmoi-cgo-musl_linux_amd64/chezmoi | tee /dev/stderr | grep -q "statically linked" - ./dist/chezmoi-cgo-glibc_linux_amd64/chezmoi --version | tee /dev/stderr | grep -q "chezmoi version v2" - ./dist/chezmoi-cgo-musl_linux_amd64/chezmoi --version | tee /dev/stderr | grep -q "chezmoi version v2" - ./dist/chezmoi-nocgo_linux_386/chezmoi --version | tee /dev/stderr | grep -q "chezmoi version v2" - - name: Upload artifact chezmoi-darwin-amd64 - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b with: name: chezmoi-darwin-amd64 - path: dist/chezmoi-nocgo_darwin_amd64/chezmoi - - name: Upload artifact chezmoi-darwin-arm64 + path: dist/chezmoi-nocgo_darwin_amd64_v1/chezmoi + - name: upload-artifact-chezmoi-darwin-arm64 if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b with: name: chezmoi-darwin-arm64 path: dist/chezmoi-nocgo_darwin_arm64/chezmoi - - name: Upload artifact chezmoi-illumos-amd64 - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - uses: actions/upload-artifact@v2 - with: - name: chezmoi-illumos-amd64 - path: dist/chezmoi-nocgo_illumos_amd64/chezmoi - - name: Upload artifact chezmoi-linux-amd64 + - name: upload-artifact-chezmoi-linux-amd64 if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b with: name: chezmoi-linux-amd64 - path: dist/chezmoi-cgo-glibc_linux_amd64/chezmoi - - name: Upload artifact chezmoi-linux-musl-amd64 + path: dist/chezmoi-cgo-glibc_linux_amd64_v1/chezmoi + - name: upload-artifact-chezmoi-linux-musl-amd64 if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b with: - name: chezmoi-linux-musl-amd64 - path: dist/chezmoi-cgo-musl_linux_amd64/chezmoi - - name: Upload artifact chezmoi-solaris-amd64 + name: chezmoi-linux-amd64-musl + path: dist/chezmoi-cgo-musl_linux_amd64_v1/chezmoi + - name: upload-artifact-chezmoi-windows-amd64.exe if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b with: - name: chezmoi-solaris-amd64 - path: dist/chezmoi-nocgo_solaris_amd64/chezmoi - - name: Upload artifact chezmoi-windows-amd64.exe - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - uses: actions/upload-artifact@v2 - with: - name: chezmoi-windows-amd64.exe - path: dist/chezmoi-nocgo_windows_amd64/chezmoi.exe - test-ubuntu-go1-16: + name: chezmoi-windows-amd64 + path: dist/chezmoi-nocgo_windows_amd64_v1/chezmoi.exe + test-ubuntu: needs: changes - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 # use older Ubuntu for older glibc + permissions: + contents: read steps: - - name: Set up Go - uses: actions/setup-go@v2 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 with: - go-version: 1.16.x - - name: Checkout - uses: actions/checkout@v2 - - name: Cache Go modules - uses: actions/cache@v2 + fetch-depth: 0 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-ubuntu-go-1-16-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-ubuntu-go-1-16- - - name: Build + go-version: ${{ env.GO_VERSION }} + - name: install-age + if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + run: | + cd "$(mktemp -d)" + curl -fsSL "https://github.com/FiloSottile/age/releases/download/v${AGE_VERSION}/age-v${AGE_VERSION}-linux-amd64.tar.gz" | tar xzf - + sudo install -m 755 age/age /usr/local/bin + sudo install -m 755 age/age-keygen /usr/local/bin + - name: install-rage + run: | + cd "$(mktemp -d)" + curl -fsSL "https://github.com/str4d/rage/releases/download/v${RAGE_VERSION}/rage-v${RAGE_VERSION}-x86_64-linux.tar.gz" | tar xzf - + sudo install -m 755 rage/rage /usr/local/bin + sudo install -m 755 rage/rage-keygen /usr/local/bin + - name: build run: | go build ./... - - name: Run + - name: run run: | go run . --version - - name: Install age + - name: test-umask-022 + if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + env: + CHEZMOI_GITHUB_TOKEN: ${{ secrets.CHEZMOI_GITHUB_TOKEN }} run: | - cd $(mktemp -d) - curl -fsSL https://github.com/FiloSottile/age/releases/download/v${AGE_VERSION}/age-v${AGE_VERSION}-linux-amd64.tar.gz | tar xzf - - sudo install -m 755 age/age /usr/local/bin - sudo install -m 755 age/age-keygen /usr/local/bin - - name: Test + go test -ldflags="-X github.com/twpayne/chezmoi/v2/internal/chezmoitest.umaskStr=0o022" -race -timeout=1h ./... + - name: test-umask-002 + if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + env: + CHEZMOI_GITHUB_TOKEN: ${{ secrets.CHEZMOI_GITHUB_TOKEN }} run: | - go test ./... + go test -ldflags="-X github.com/twpayne/chezmoi/v2/internal/chezmoitest.umaskStr=0o002" -race -timeout=1h ./... + test-website: + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 + with: + go-version: ${{ env.GO_VERSION }} + - name: install-website-dependencies + run: | + pip3 install -r assets/chezmoi.io/requirements.txt + - name: build-website + run: mkdocs build -f assets/chezmoi.io/mkdocs.yml + env: + CHEZMOI_GITHUB_TOKEN: ${{ secrets.CHEZMOI_GITHUB_TOKEN }} test-windows: needs: changes if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: windows-2019 + runs-on: windows-2022 + permissions: + contents: read steps: - - name: Set up Go - uses: actions/setup-go@v2 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 with: go-version: ${{ env.GO_VERSION }} - - name: Checkout - uses: actions/checkout@v2 - - name: Cache Go modules - uses: actions/cache@v2 + - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- - - name: Build + - name: build run: | go build ./... - - name: Run + - name: run run: | go run . --version - - name: Install age - run: | - $env:PATH = "C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\;C:\ProgramData\chocolatey\bin" - [Environment]::SetEnvironmentVariable("Path", $env:PATH, "Machine") - choco install --no-progress --yes age.portable - - name: Install gpg4win - run: | - $env:PATH = "C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\;C:\ProgramData\chocolatey\bin" - [Environment]::SetEnvironmentVariable("Path", $env:PATH, "Machine") - choco install --no-progress --yes gpg4win - echo "C:\Program Files (x86)\GnuPG\bin" >> $env:GITHUB_PATH - - name: Upload chocolatey log - if: failure() - uses: actions/upload-artifact@v2 - with: - name: chocolatey.log - path: C:/ProgramData/chocolatey/logs/chocolatey.log - - name: Test + - name: test + env: + CHEZMOI_GITHUB_TOKEN: ${{ secrets.CHEZMOI_GITHUB_TOKEN }} run: | go test -race ./... - test-voidlinux: - needs: changes - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: ubuntu-20.04 - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Test - run: | - ( cd assets/docker && ./test.sh voidlinux ) check: - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 + permissions: + contents: read steps: - - name: Set up Go - uses: actions/setup-go@v2 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 with: - go-version: ${{ env.GO_VERSION }} - - name: Checkout - uses: actions/checkout@v2 - - name: Cache Go modules - uses: actions/cache@v2 + fetch-depth: 0 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - name: Generate + go-version: ${{ env.GO_VERSION }} + - name: generate run: | go generate git diff --exit-code - - name: ShellCheck - uses: ludeeus/action-shellcheck@1.1.0 + - name: actionlint + run: | + go install "github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION}" + actionlint + - uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 with: - ignore: completions - - name: Whitespace + ignore_paths: completions + - name: editorconfig-checker + run: | + GOOS="$(go env GOOS)" + GOARCH="$(go env GOARCH)" + curl -sSfL "https://github.com/editorconfig-checker/editorconfig-checker/releases/download/v${EDITORCONFIG_CHECKER_VERSION}/ec-${GOOS}-${GOARCH}.tar.gz" | tar -xzf - + "bin/ec-${GOOS}-${GOARCH}" + - name: lint-whitespace run: | go run ./internal/cmds/lint-whitespace - - name: Typos + - name: lint-txtar run: | - go install github.com/twpayne/findtypos@v0.0.1 - findtypos -format=github-actions chezmoi . + find . -name '*.txtar' -print0 | xargs -0 go run ./internal/cmds/lint-txtar + - name: find-typos + run: | + go install "github.com/twpayne/find-typos@v${FIND_TYPOS_VERSION}" + find-typos -format=github-actions chezmoi . + - name: lint-commit-messages + if: github.event_name == 'push' + run: | + go run ./internal/cmds/lint-commit-messages HEAD~1..HEAD + - name: lint-commit-messages + if: github.event_name == 'pull_request' && github.event.pull_request.draft == false + run: | + go run ./internal/cmds/lint-commit-messages ${{ github.event.pull_request.head.sha }}~${{ github.event.pull_request.commits }}..${{ github.event.pull_request.head.sha }} lint: + name: lint-${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + runs-on: + - macos-14 + - ubuntu-22.04 + - windows-2022 needs: changes if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - runs-on: ubuntu-18.04 + runs-on: ${{ matrix.runs-on }} + permissions: + contents: read steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Lint - uses: golangci/golangci-lint-action@v2 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 + with: + go-version: stable + - uses: golangci/golangci-lint-action@a4f60bb28d35aeee14e6880718e0c85ff1882e64 with: version: v${{ env.GOLANGCI_LINT_VERSION }} args: --timeout=5m - release: # FIXME this should be merged into test-ubuntu above + release: # FIXME this should be merged into test-release above if: startsWith(github.ref, 'refs/tags/') needs: - check - lint + - test-alpine - test-archlinux - - test-debian-i386 - - test-fedora - - test-freebsd - test-macos - - test-openbsd - - test-openindiana + - test-oldstable-go + - test-release - test-ubuntu - - test-ubuntu-go1-16 - - test-voidlinux + - test-website - test-windows - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 # use older Ubuntu for older glibc + permissions: + contents: write steps: - - name: Install build dependencies - run: | - sudo apt-get update - sudo apt-get -yq --no-install-suggests --no-install-recommends install musl-tools snapcraft - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: ${{ env.GO_VERSION }} - - name: Checkout - uses: actions/checkout@v2 + - name: install-build-dependencies + run: | + sudo apt-get --quiet update + sudo apt-get --no-install-suggests --no-install-recommends --quiet --yes install musl-tools snapcraft + mkdir -p /opt/chocolatey + wget -q -O - "https://github.com/chocolatey/choco/releases/download/${CHOCOLATEY_VERSION}/chocolatey.v${CHOCOLATEY_VERSION}.tar.gz" | tar -xz -C "/opt/chocolatey" + echo '#!/bin/bash' >> /usr/local/bin/choco + echo 'mono /opt/chocolatey/choco.exe $@' >> /usr/local/bin/choco + chmod +x /usr/local/bin/choco + - name: check-snapcraft-credentials + run: snapcraft whoami + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 with: fetch-depth: 0 - - name: Cache Go modules - uses: actions/cache@v2 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - name: Snapcraft login - env: - SNAPCRAFT_LOGIN: ${{ secrets.SNAPCRAFT_LOGIN }} + go-version: ${{ env.GO_VERSION }} + - uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 + - name: create-syso run: | - snapcraft login --with <(echo "$SNAPCRAFT_LOGIN" | base64 -d) - - name: Release - uses: goreleaser/goreleaser-action@v2 + make create-syso + - uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 with: - version: latest - args: release + version: ${{ env.GORELEASER_VERSION }} + args: release --timeout=1h env: + CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} + COSIGN_PWD: ${{ secrets.COSIGN_PWD }} GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} - # The following is needed because chezmoi upgrade and - # assets/scripts/install.sh have inconsistently looked for - # chezmoi_${VERSION}_checksums.txt and checksums.txt. To ensure - # compatibility with all versions, upload checksums.txt as well. - - name: Upload checksums.txt - run: | - VERSION=${GITHUB_REF##*/v} - cp dist/chezmoi_${VERSION}_checksums.txt dist/checksums.txt - gh release upload v${VERSION} dist/checksums.txt + SCOOP_GITHUB_TOKEN: ${{ secrets.SCOOP_GITHUB_TOKEN }} + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + deploy-website: + needs: + - release + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + with: + fetch-depth: 0 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 + with: + go-version: ${{ env.GO_VERSION }} + - name: prepare-chezmoi.io + run: | + pip3 install -r assets/chezmoi.io/requirements.txt + mkdocs build -f assets/chezmoi.io/mkdocs.yml env: - GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} + CHEZMOI_GITHUB_TOKEN: ${{ secrets.CHEZMOI_GITHUB_TOKEN }} + - name: push-chezmoi.io + run: | + ( cd assets/chezmoi.io && mkdocs gh-deploy ) + - name: prepare-get.chezmoi.io + run: | + cp assets/scripts/install.sh assets/get.chezmoi.io/index.html + cp assets/scripts/install-local-bin.sh assets/get.chezmoi.io/lb + cp assets/scripts/install.ps1 assets/get.chezmoi.io/ps1 + cp LICENSE assets/get.chezmoi.io/LICENSE + - name: push-get.chezmoi.io + uses: cpina/github-action-push-to-another-repository@07c4d7b3def0a8ebe788a8f2c843a4e1de4f6900 + env: + SSH_DEPLOY_KEY: ${{ secrets.GET_CHEZMOI_IO_SSH_DEPLOY_KEY }} + with: + source-directory: assets/get.chezmoi.io + destination-github-username: chezmoi + destination-repository-name: get.chezmoi.io + target-branch: gh-pages + commit-message: 'chore: Update from ORIGIN_COMMIT' + user-email: twpayne@gmail.com diff --git a/.gitignore b/.gitignore index 8331ce175e7..c57c20c68d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,17 @@ /.vagrant +/COMMIT +/bin/actionlint +/bin/chezmoi +/bin/editorconfig-checker +/bin/find-typos /bin/gofumpt +/bin/golines /bin/golangci-lint +/bin/goreleaser +/bin/goversioninfo /chezmoi /chezmoi.exe /coverage.out /dist +/resource_windows_*.syso +/versioninfo.json diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index faac9d15ca3..00000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "assets/chezmoi.io/themes/book"] - path = assets/chezmoi.io/themes/book - url = https://github.com/alex-shpak/hugo-book diff --git a/.golangci.yml b/.golangci.yml index f4ec3f82de8..ceccae9289e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,66 +1,91 @@ +run: + go: '1.21' + linters: enable: - asciicheck - bidichk - bodyclose - - contextcheck - - deadcode - - depguard + - canonicalheader + - containedctx + - decorder - dogsled - - dupl + - dupword - durationcheck + - err113 - errcheck + - errchkjson - errname - errorlint - exportloopref + - fatcontext - forbidigo - forcetypeassert - gci + - gocheckcompilerdirectives + - gochecksumtype - gocritic - godot - - goerr113 - gofmt - gofumpt - goimports - - gomoddirectives - gomodguard - goprintffuncname - gosec - gosimple + - gosmopolitan - govet - - ifshort + - grouper - importas + - inamedparam - ineffassign - - lll + - interfacebloat + - loggercheck - makezero + - mirror - misspell - nilerr - noctx - nolintlint + - nosprintfhostport + - perfsprint - prealloc - predeclared - promlinter + - protogetter + - reassign - revive - rowserrcheck + - sloglint + - spancheck - sqlclosecheck - staticcheck - - structcheck - stylecheck + - tagalign - tagliatelle - tenv + - testableexamples + - testifylint - thelper - typecheck - unconvert - unparam - unused - - varcheck + - usestdlibvars - wastedassign - whitespace + - zerologlint disable: + - asasalint + - contextcheck + - copyloopvar - cyclop + - depguard + - dupl - exhaustive - - exhaustivestruct + - exhaustruct - funlen + - ginkgolinter - gochecknoglobals - gochecknoinits - gocognit @@ -69,11 +94,16 @@ linters: - godox - goheader - gomnd - - maligned + - gomoddirectives + - ireturn + - lll + - maintidx + - musttag - nakedret - nestif - nilnil - nlreturn + - nonamedreturns - paralleltest - testpackage - tparallel @@ -84,27 +114,53 @@ linters: linters-settings: forbidigo: forbid: + - ^archive/zip\. + - ^compress/gzip\. - ^fmt\.Print.*$ - ^ioutil\..*$ - - ^os\.(DirEntry|FileInfo|FileMode|Is.*|Mode.*)$ + - ^os\.(DirEntry|ErrExist|ErrNotExist|FileInfo|FileMode|Is.*|Mode.*)$ + gci: + sections: + - standard + - default + - prefix(github.com/twpayne/chezmoi) gofumpt: extra-rules: true + module-path: github.com/twpayne/chezmoi goimports: local-prefixes: github.com/twpayne/chezmoi + govet: + disable: + - fieldalignment + - shadow + enable-all: true misspell: locale: US + ignore-words: + - ackward + stylecheck: + checks: + - all issues: + include: + - EXC0011 # include issues about comments from `stylecheck` exclude-rules: - linters: - - goerr113 - text: "do not define dynamic errors, use wrapped static errors instead" + - err113 + text: do not define dynamic errors, use wrapped static errors instead + - linters: + - revive + text: unused-parameter - linters: - forbidigo - gosec - - lll path: ^internal/cmds/ - linters: + - forcetypeassert + - gosec + path: _test\.go$ + - linters: + - forbidigo - gosec - - lll - path: "_test\\.go$" + path: assets/scripts/generate-commit.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ea728748c73..7387ffe4d8b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,7 +1,10 @@ +version: 2 + project_name: chezmoi before: hooks: + - go run assets/scripts/generate-commit.go -o COMMIT - go mod download all builds: @@ -13,12 +16,12 @@ builds: goarch: - amd64 ldflags: - - '-s' - - '-w' - - '-X main.version={{ .Version }}' - - '-X main.commit={{ .Commit }}' - - '-X main.date={{ .Date }}' - - '-X main.builtBy=goreleaser' + - -s + - -w + - -X main.version={{ .Version }} + - -X main.commit={{ .Commit }} + - -X main.date={{ .Date }} + - -X main.builtBy=goreleaser - id: chezmoi-cgo-musl env: - CC=/usr/bin/musl-gcc @@ -28,44 +31,54 @@ builds: 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"' + - -s + - -w + - -X main.version={{ .Version }} + - -X main.commit={{ .Commit }} + - -X main.date={{ .Date }} + - -X main.builtBy=goreleaser + - -linkmode external + - --extldflags "-static" - id: chezmoi-nocgo env: - CGO_ENABLED=0 goos: + - android - darwin - freebsd - - illumos - linux - openbsd - - solaris - windows goarch: - - 386 + - '386' - amd64 - arm - arm64 + - loong64 + - mips64 + - mips64le - ppc64 - ppc64le + - riscv64 + - s390x goarm: - - "" + - '' ldflags: - - '-s' - - '-w' - - '-X main.version={{ .Version }}' - - '-X main.commit={{ .Commit }}' - - '-X main.date={{ .Date }}' - - '-X main.builtBy=goreleaser' + - -s + - -w + - -X main.version={{ .Version }} + - -X main.commit={{ .Commit }} + - -X main.date={{ .Date }} + - -X main.builtBy=goreleaser ignore: + - goos: android + goarch: '386' + - goos: android + goarch: amd64 + - goos: android + goarch: arm - goos: darwin - goarch: 386 + goarch: '386' - goos: linux goarch: amd64 @@ -77,9 +90,14 @@ archives: - LICENSE - README.md - completions/* - - docs/* - replacements: - 386: i386 + name_template: >- + {{- .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- if eq .Arch "386" }}i386 + {{- else if eq .Arch "mips64" }}mips64_hardfloat + {{- else if eq .Arch "mips64le" }}mips64le_hardfloat + {{- else }}{{ .Arch }}{{ end -}} format_overrides: - goos: windows format: zip @@ -90,8 +108,7 @@ archives: - LICENSE - README.md - completions/* - - docs/* - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}-glibc_{{ .Arch }}" + name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}-glibc_{{ .Arch }}' - id: musl builds: - chezmoi-cgo-musl @@ -99,32 +116,49 @@ archives: - LICENSE - README.md - completions/* - - docs/* - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}-musl_{{ .Arch }}" + name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}-musl_{{ .Arch }}' changelog: - sort: asc + groups: + - title: Features + regexp: ^.*?feat(\([[:word:]]+\))??!?:.+$ + order: 0 + - title: Fixes + regexp: ^.*?fix(\([[:word:]]+\))??!?:.+$ + order: 1 + - title: Documentation updates + regexp: ^.*?docs?(\([[:word:]]+\))??!?:.+$ + order: 2 + - title: Other + order: 999 filters: exclude: - - "^chore:" - - "^chore\\(deps\\):" - - "^docs:" - - "^test:" - - Merge pull request - - Merge branch + - ^.*?chore(\(.*\))??!?:.+$ checksum: + extra_files: + - glob: ./dist/chezmoi-nocgo_darwin_amd64_v1/chezmoi + name_template: chezmoi-darwin-amd64 + - glob: ./dist/chezmoi-nocgo_darwin_arm64/chezmoi + name_template: chezmoi-darwin-arm64 + - glob: ./dist/chezmoi-cgo-glibc_linux_amd64_v1/chezmoi + name_template: chezmoi-linux-amd64 + - glob: ./dist/chezmoi-cgo-musl_linux_amd64_v1/chezmoi + name_template: chezmoi-linux-amd64-musl + - glob: ./dist/chezmoi-nocgo_windows_amd64_v1/chezmoi.exe + name_template: chezmoi-windows-amd64.exe nfpms: - builds: - chezmoi-cgo-glibc - chezmoi-nocgo - vendor: "Tom Payne " - homepage: "https://chezmoi.io/" - maintainer: "Tom Payne " - description: "Manage your dotfiles across multiple diverse machines, securely." + vendor: Tom Payne + homepage: https://chezmoi.io/ + maintainer: Tom Payne + description: Manage your dotfiles across multiple diverse machines, securely. license: MIT formats: + - archlinux - deb - rpm dependencies: @@ -132,70 +166,166 @@ nfpms: bindir: /usr/bin overrides: deb: - file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - replacements: - 386: i386 - arm: armel + file_name_template: >- + {{- .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- if eq .Arch "386" }}i386 + {{- else if eq .Arch "arm" }}armel + {{- else }}{{ .Arch }}{{ end -}} contents: - src: completions/chezmoi-completion.bash dst: /usr/share/bash-completion/completions/chezmoi - src: completions/chezmoi.fish - dst: /usr/share/fish/completions/chezmoi.fish + dst: /usr/share/fish/vendor_completions.d/chezmoi.fish - src: completions/chezmoi.zsh dst: /usr/share/zsh/vendor-completions/_chezmoi rpm: - file_name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Arch }}" - replacements: - amd64: x86_64 - 386: i686 - arm: armhfp - arm64: aarch64 + file_name_template: >- + {{- .ProjectName }}- + {{- .Version }}- + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i686 + {{- else if eq .Arch "arm" }}armhfp + {{- else if eq .Arch "arm64" }}aarch64 + {{- else }}{{ .Arch }}{{ end -}} contents: - src: completions/chezmoi-completion.bash dst: /usr/share/bash-completion/completions/chezmoi - src: completions/chezmoi.fish - dst: /usr/share/fish/completions/chezmoi.fish + dst: /usr/share/fish/vendor_completions.d/chezmoi.fish - src: completions/chezmoi.zsh dst: /usr/share/zsh/site-functions/_chezmoi - id: apks builds: - chezmoi-cgo-musl - chezmoi-nocgo - vendor: "Tom Payne " - homepage: "https://chezmoi.io/" - maintainer: "Tom Payne " - description: "Manage your dotfiles across multiple diverse machines, securely." + vendor: Tom Payne + homepage: https://chezmoi.io/ + maintainer: Tom Payne + description: Manage your dotfiles across multiple diverse machines, securely. license: MIT formats: - apk bindir: /usr/bin +chocolateys: +- owners: twpayne + authors: Tom Payne + project_url: https://chezmoi.io + url_template: https://github.com/twpayne/chezmoi/releases/download/v{{ .Version }}/{{ .ArtifactName }} + icon_url: https://github.com/twpayne/chezmoi/raw/master/assets/images/logo-144px.png + copyright: Copyright (c) 2018-{{ .Now.Format "2006" }} Tom Payne + license_url: https://github.com/twpayne/chezmoi/blob/master/LICENSE + project_source_url: https://github.com/twpayne/chezmoi + docs_url: https://chezmoi.io + bug_tracker_url: https://github.com/twpayne/chezmoi/issues + tags: configuration dotfile dotfiles + summary: Manage your dotfiles across multiple diverse machines, securely. + description: | + ## What does chezmoi do? + + chezmoi helps you manage your personal configuration files (dotfiles, like `~/.gitconfig`) across multiple machines. + + chezmoi is helpful if you have spent time customizing the tools you use (e.g. shells, editors, and version control systems) and want to keep machines running different accounts (e.g. home and work) and/or different operating systems (e.g. Linux, macOS, and Windows) in sync, while still being able to easily cope with differences from machine to machine. + + chezmoi scales from the trivial (e.g. copying a few dotfiles onto a Raspberry Pi, development container, or virtual machine) to complex long-lived multi-machine development environments (e.g. keeping any number of home and work, Linux, macOS, and Windows machines in sync). In all cases you only need to maintain a single source of truth (a single branch in git) and getting started only requires adding a single binary to your machine (which you can do with `curl`, `wget`, or `scp`). + + chezmoi has strong support for security, allowing you to manage secrets (e.g. passwords, access tokens, and private keys) securely and seamlessly using a password manager and/or encrypt whole files with your favorite encryption tool. + release_notes: '{{ .Changelog }}' + api_key: '{{ .Env.CHOCOLATEY_API_KEY }}' + release: + extra_files: + - glob: ./assets/cosign/cosign.pub + name_template: chezmoi_cosign.pub + - glob: ./dist/chezmoi-nocgo_darwin_amd64_v1/chezmoi + name_template: chezmoi-darwin-amd64 + - glob: ./dist/chezmoi-nocgo_darwin_arm64/chezmoi + name_template: chezmoi-darwin-arm64 + - glob: ./dist/chezmoi-cgo-glibc_linux_amd64_v1/chezmoi + name_template: chezmoi-linux-amd64 + - glob: ./dist/chezmoi-cgo-musl_linux_amd64_v1/chezmoi + name_template: chezmoi-linux-amd64-musl + - glob: ./dist/chezmoi-nocgo_windows_amd64_v1/chezmoi.exe + name_template: chezmoi-windows-amd64.exe -scoop: - bucket: +scoops: +- repository: owner: twpayne name: scoop-bucket + token: '{{ .Env.SCOOP_GITHUB_TOKEN }}' commit_author: name: Tom Payne email: twpayne@gmail.com - homepage: "https://chezmoi.io" - description: "Manage your dotfiles across multiple diverse machines, securely." + homepage: https://chezmoi.io + description: Manage your dotfiles across multiple diverse machines, securely. license: MIT +signs: +- cmd: cosign + stdin: '{{ .Env.COSIGN_PWD }}' + args: + - sign-blob + - --key=assets/cosign/cosign.key + - --output-signature=${signature} + - --yes + - ${artifact} + artifacts: checksum + snapcrafts: - builds: - chezmoi-cgo-glibc - chezmoi-nocgo - summary: "Manage your dotfiles across multiple diverse machines, securely." - description: "Manage your dotfiles across multiple diverse machines, securely." + summary: Manage your dotfiles across multiple diverse machines, securely. + description: Manage your dotfiles across multiple diverse machines, securely. publish: true grade: stable confinement: classic + license: MIT apps: chezmoi: + command: chezmoi completer: completions/chezmoi-completion.bash source: enabled: true prefix_template: '{{ .ProjectName }}-{{ .Version }}/' + files: + - COMMIT + +winget: +- name: chezmoi + publisher: twpayne + publisher_url: https://github.com/twpayne + short_description: Manage your dotfiles across multiple diverse machines, securely. + license: MIT + commit_author: + name: Tom Payne + email: twpayne@gmail.com + homepage: https://chezmoi.io + license_url: https://github.com/twpayne/chezmoi/blob/master/LICENSE + copyright: Copyright (c) 2018-{{ .Now.Format "2006" }} Tom Payne + release_notes: '{{ .Changelog }}' + release_notes_url: https://github.com/twpayne/chezmoi/releases/tag/{{ .Tag }} + tags: + - cli + - configuration + - dotbot + - dotfile + - dotfiles + - stow + - yadm + author: Tom Payne + publisher_support_url: https://github.com/twpayne/chezmoi/issues + repository: + owner: twpayne + name: winget-pkgs + branch: chezmoi-{{ .Version }} + token: '{{ .Env.WINGET_GITHUB_TOKEN }}' + pull_request: + enabled: true + base: + owner: microsoft + name: winget-pkgs + branch: master diff --git a/LICENSE b/LICENSE index 24f8fc050ff..ea8e16df597 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2018 Tom Payne +Copyright (c) 2018-2024 Tom Payne Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 9875952cbfe..1befd42d3ba 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,15 @@ GO?=go -GOLANGCI_LINT_VERSION=$(shell grep GOLANGCI_LINT_VERSION: .github/workflows/main.yml | awk '{ print $$2 }') +GOOS=$(shell ${GO} env GOOS) +GOARCH=$(shell ${GO} env GOARCH) +ACTIONLINT_VERSION=$(shell awk '/ACTIONLINT_VERSION:/ { print $$2 }' .github/workflows/main.yml) +EDITORCONFIG_CHECKER_VERSION=$(shell awk '/EDITORCONFIG_CHECKER_VERSION:/ { print $$2 }' .github/workflows/main.yml) +FIND_TYPOS_VERSION=$(shell awk '/FIND_TYPOS_VERSION:/ { print $$2 }' .github/workflows/main.yml) +GOFUMPT_VERSION=$(shell awk '/GOFUMPT_VERSION:/ { print $$2 }' .github/workflows/main.yml) +GOLANGCI_LINT_VERSION=$(shell awk '/GOLANGCI_LINT_VERSION:/ { print $$2 }' .github/workflows/main.yml) +GOLINES_VERSION=$(shell awk '/GOLINES_VERSION:/ { print $$2 }' .github/workflows/main.yml) +GORELEASER_VERSION=$(shell awk '/GORELEASER_VERSION:/ { print $$2 }' .github/workflows/main.yml) +GOVERSIONINFO_VERSION=$(shell awk '/GOVERSIONINFO_VERSION:/ { print $$2 }' .github/workflows/main.yml) +UPSTREAM=$(shell git remote -v | awk '/github.com[:\/]twpayne\/chezmoi(.git)? \(fetch\)/ {print $$1}') ifdef VERSION GO_LDFLAGS+=-X main.version=${VERSION} endif @@ -18,7 +28,7 @@ PREFIX?=/usr/local default: build .PHONY: smoketest -smoketest: run build-all test lint format +smoketest: run build-all test lint shellcheck format .PHONY: build build: @@ -30,7 +40,8 @@ endif .PHONY: install install: build - install -m 755 chezmoi "${DESTDIR}${PREFIX}/bin" + mkdir -p "${DESTDIR}${PREFIX}/bin" + install -m 755 --target-directory "${DESTDIR}${PREFIX}/bin" chezmoi .PHONY: install-from-git-working-copy install-from-git-working-copy: @@ -64,25 +75,32 @@ build-linux: GOOS=linux GOARCH=amd64 ${GO} build -tags=noupgrade -o /dev/null . .PHONY: build-windows -build-windows: +build-windows: create-syso GOOS=windows GOARCH=amd64 ${GO} build -o /dev/null . .PHONY: run run: ${GO} run . --version +.PHONY: test-all +test-all: test test-release rm-dist test-docker test-vagrant + +.PHONY: rm-dist +rm-dist: + rm -rf dist + .PHONY: test test: - ${GO} test -ldflags="-X github.com/twpayne/chezmoi/internal/chezmoitest.umaskStr=0o022" ./... - ${GO} test -ldflags="-X github.com/twpayne/chezmoi/internal/chezmoitest.umaskStr=0o002" ./... + ${GO} test -ldflags="-X github.com/twpayne/chezmoi/v2/internal/chezmoitest.umaskStr=0o022" ./... + ${GO} test -ldflags="-X github.com/twpayne/chezmoi/v2/internal/chezmoitest.umaskStr=0o002" ./... .PHONY: test-docker test-docker: - ( cd assets/docker && ./test.sh archlinux fedora voidlinux ) + ( cd assets/docker && ./test.sh alpine archlinux fedora ) .PHONY: test-vagrant test-vagrant: - ( cd assets/vagrant && ./test.sh debian11-i386 freebsd13 openbsd6 openindiana ) + ( cd assets/vagrant && ./test.sh freebsd14 ) .PHONY: coverage-html coverage-html: coverage @@ -90,51 +108,113 @@ coverage-html: coverage .PHONY: coverage coverage: - ${GO} test -coverprofile=coverage.out -coverpkg=./... ./... + ${GO} test -coverprofile=coverage.out -coverpkg=github.com/twpayne/chezmoi/... ./... .PHONY: generate generate: ${GO} generate .PHONY: lint -lint: ensure-golangci-lint +lint: ensure-actionlint ensure-editorconfig-checker ensure-find-typos ensure-golangci-lint + ./bin/actionlint + ./bin/editorconfig-checker ./bin/golangci-lint run ${GO} run ./internal/cmds/lint-whitespace + find . -name \*.txtar | xargs ${GO} run ./internal/cmds/lint-txtar + ./bin/find-typos chezmoi . + go run ./internal/cmds/lint-commit-messages ${UPSTREAM}/master..HEAD .PHONY: format -format: ensure-gofumpt - find . -name \*.go | xargs ./bin/gofumpt -w +format: ensure-gofumpt ensure-golines + find . -name \*.go | xargs ./bin/golines --base-formatter="./bin/gofumpt -extra" --max-len=128 --write-output + find . -name \*.txtar | xargs ${GO} run ./internal/cmds/lint-txtar -w + +.PHONY: format-yaml +format-yaml: + find . -name \*.yaml -o -name \*.yml | xargs ./assets/scripts/format-yaml.py + +.PHONY: create-syso +create-syso: ensure-goversioninfo + ${GO} run ./internal/cmds/execute-template -output ./versioninfo.json ./assets/templates/versioninfo.json.tmpl + ./bin/goversioninfo -platform-specific .PHONY: ensure-tools -ensure-tools: ensure-gofumpt ensure-golangci-lint +ensure-tools: \ + ensure-actionlint \ + ensure-find-typos \ + ensure-gofumpt \ + ensure-golangci-lint \ + ensure-golines \ + ensure-goreleaser \ + ensure-goversioninfo + +.PHONY: ensure-actionlint +ensure-actionlint: + if [ ! -x bin/actionlint ] || ( ./bin/actionlint --version | grep -Fqv "v${ACTIONLINT_VERSION}" ) ; then \ + mkdir -p bin ; \ + GOBIN=$(shell pwd)/bin ${GO} install "github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION}" ; \ + fi + +.PHONY: ensure-editorconfig-checker +ensure-editorconfig-checker: + if [ ! -x bin/editorconfig-checker ] || ( ./bin/editorconfig-checker --version | grep -Fqv "v${EDITORCONFIG_CHECKER_VERSION}" ) ; then \ + curl -sSfL "https://github.com/editorconfig-checker/editorconfig-checker/releases/download/v${EDITORCONFIG_CHECKER_VERSION}/ec-${GOOS}-${GOARCH}.tar.gz" | tar -xzf - ; \ + mv "bin/ec-${GOOS}-${GOARCH}" bin/editorconfig-checker ; \ + fi + +.PHONY: ensure-find-typos +ensure-find-typos: + if [ ! -x bin/find-typos ] ; then \ + mkdir -p bin ; \ + GOBIN=$(shell pwd)/bin ${GO} install "github.com/twpayne/find-typos@v${FIND_TYPOS_VERSION}" ; \ + fi .PHONY: ensure-gofumpt ensure-gofumpt: - if [ ! -x bin/gofumpt ] ; then \ + if [ ! -x bin/gofumpt ] || ( ./bin/gofumpt --version | grep -Fqv "v${GOFUMPT_VERSION}" ) ; then \ mkdir -p bin ; \ - GOBIN=$(shell pwd)/bin ${GO} install mvdan.cc/gofumpt@v0.2.0 ; \ + GOBIN=$(shell pwd)/bin ${GO} install "mvdan.cc/gofumpt@v${GOFUMPT_VERSION}" ; \ + fi + +.PHONY: ensure-golines +ensure-golines: + if [ ! -x bin/golines ] || ( ./bin/actionlint --version | grep -Fqv "v${GOLINES_VERSION}" ) ; then \ + mkdir -p bin ; \ + GOBIN=$(shell pwd)/bin ${GO} install "github.com/segmentio/golines@v${GOLINES_VERSION}" ; \ fi .PHONY: ensure-golangci-lint ensure-golangci-lint: - if [ ! -x bin/golangci-lint ] || ( ./bin/golangci-lint --version | grep -Fqv "version ${GOLANGCI_LINT_VERSION}" ) ; then \ + if [ ! -x bin/golangci-lint ] || ( ./bin/golangci-lint version | grep -Fqv "version ${GOLANGCI_LINT_VERSION}" ) ; then \ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- v${GOLANGCI_LINT_VERSION} ; \ fi +.PHONY: ensure-goreleaser +ensure-goreleaser: + if [ ! -x bin/goreleaser ] || ( ./bin/goreleaser --version | grep -Fqv "${GORELEASER_VERSION}" ) ; then \ + GOBIN=$(shell pwd)/bin ${GO} install "github.com/goreleaser/goreleaser/v2@v${GORELEASER_VERSION}" ; \ + fi + +.PHONY: ensure-goversioninfo +ensure-goversioninfo: + if [ ! -x bin/goversioninfo ] ; then \ + GOBIN=$(shell pwd)/bin ${GO} install "github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v${GOVERSIONINFO_VERSION}" ; \ + fi + .PHONY: release -release: - goreleaser release \ - --rm-dist \ +release: ensure-goreleaser + ./bin/goreleaser release \ + --clean \ ${GORELEASER_FLAGS} +.PHONY: shellcheck +shellcheck: + find . -type f -name \*.sh | xargs shellcheck + .PHONY: test-release -test-release: - goreleaser release \ - --rm-dist \ - --skip-publish \ +test-release: ensure-goreleaser + ./bin/goreleaser release \ + --clean \ + --skip=chocolatey,sign \ --snapshot \ ${GORELEASER_FLAGS} - -.PHONY: update-devcontainer -update-devcontainer: - rm -rf .devcontainer && mkdir .devcontainer && curl -sfL https://github.com/microsoft/vscode-dev-containers/archive/master.tar.gz | tar -xzf - -C .devcontainer --strip-components=4 vscode-dev-containers-master/containers/go/.devcontainer diff --git a/README.md b/README.md index c8f8dd1db5c..0a01dc0be41 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,19 @@ -# ![chezmoi logo](logo-144px.svg) chezmoi +# ![chezmoi logo](assets/images/logo-144px.svg) chezmoi [![GitHub Release](https://img.shields.io/github/release/twpayne/chezmoi.svg)](https://github.com/twpayne/chezmoi/releases) Manage your dotfiles across multiple diverse machines, securely. -With chezmoi, you can install chezmoi and your dotfiles on a new, empty machine -with a single command: +chezmoi's documentation is at [chezmoi.io](https://chezmoi.io/). -```console -$ sh -c "$(curl -fsLS git.io/chezmoi)" -- init --apply -``` +If you're contributing to chezmoi, then please read the [developer guide](https://www.chezmoi.io/developer-guide/). -Updating your dotfiles on any machine is a single command: +## Contributors -```console -$ chezmoi update -``` - ---- - -* [How do I start with chezmoi now?](#how-do-i-start-with-chezmoi-now) -* [What does chezmoi do and why should I use it?](#what-does-chezmoi-do-and-why-should-i-use-it) -* [What are chezmoi's key features?](#what-are-chezmois-key-features) - * [Flexible](#flexible) - * [Personal and secure](#personal-and-secure) - * [Transparent](#transparent) - * [Declarative and robust](#declarative-and-robust) - * [Fast and easy to use](#fast-and-easy-to-use) -* [I already have a system to manage my dotfiles, why should I use chezmoi?](#i-already-have-a-system-to-manage-my-dotfiles-why-should-i-use-chezmoi) -* [How do people use chezmoi?](#how-do-people-use-chezmoi) - * [Dotfile repos using chezmoi](#dotfile-repos-using-chezmoi) - * [People talking about chezmoi](#people-talking-about-chezmoi) -* [What documentation is available?](#what-documentation-is-available) -* [License](#license) - ---- -## How do I start with chezmoi now? - -[Install chezmoi](docs/INSTALL.md) then read the [quick start -guide](docs/QUICKSTART.md). The [how-to guide](docs/HOWTO.md) covers most common -tasks, and there's both documentation on [templating](docs/TEMPLATING.md) and -[frequently asked questions](docs/FAQ.md) for specific questions. You can browse -other people's [dotfiles that use chezmoi on -GitHub](https://github.com/topics/chezmoi?o=desc&s=updated) and [dotfiles that -use chezmoi on GitLab](https://gitlab.com/explore/projects?topic=chezmoi), and -see how chezmoi [compares to other dotfile managers](docs/COMPARISON.md). For a -full description of chezmoi, consult the [reference](docs/REFERENCE.md). - ---- -## What does chezmoi do and why should I use it? - -chezmoi helps you manage your personal configuration files (dotfiles, like -`~/.gitconfig`) across multiple machines. - -chezmoi is helpful if you have spent time customizing the tools you use (e.g. -shells, editors, and version control systems) and want to keep machines running -different accounts (e.g. home and work) and/or different operating systems (e.g. -Linux, macOS, and Windows) in sync, while still being able to easily cope with -differences from machine to machine. - -chezmoi scales from the trivial (e.g. copying a few dotfiles onto a Raspberry -Pi, development container, or virtual machine) to complex long-lived -multi-machine development environments (e.g. keeping any number of home and -work, Linux, macOS, and Windows machines in sync). In all cases you only need to -maintain a single source of truth (a single branch in git) and getting started -only requires adding a single binary to your machine (which you can do with -`curl`, `wget`, or `scp`). - -chezmoi has strong support for security, allowing you to manage secrets (e.g. -passwords, access tokens, and private keys) securely and seamlessly using a -password manager and/or encrypt whole files with your favorite encryption tool. - -If you do not personalize your configuration or only ever use a single operating -system with a single account and none of your dotfiles contain secrets then you -don't need chezmoi. Otherwise, read on... - ---- - -## What are chezmoi's key features? - -### Flexible - -You can share as much configuration across machines as you want, while still -being able to control machine-specific details. Your dotfiles can be templates -(using [`text/template`](https://pkg.go.dev/text/template) syntax). Predefined -variables allow you to change behavior depending on operating system, -architecture, and hostname. chezmoi runs on all commonly-used platforms, like -Linux, macOS, and Windows. It also runs on less commonly-used platforms, like -FreeBSD, OpenBSD, and Termux. - -### Personal and secure - -Nothing leaves your machine, unless you want it to. Your configuration remains -in a git repo under your control. You can write the configuration file in the -format of your choice. chezmoi can retrieve secrets from -[1Password](https://1password.com/), [Bitwarden](https://bitwarden.com/), -[gopass](https://www.gopass.pw/), [KeePassXC](https://keepassxc.org/), -[LastPass](https://lastpass.com/), [pass](https://www.passwordstore.org/), -[Vault](https://www.vaultproject.io/), Keychain, -[Keyring](https://wiki.gnome.org/Projects/GnomeKeyring), or any command-line -utility of your choice. You can encrypt individual files with -[GnuPG](https://www.gnupg.org) or [age](https://age-encryption.org). You can -checkout your dotfiles repo on as many machines as you want without revealing -any secrets to anyone. - -### Transparent - -chezmoi includes verbose and dry run modes so you can review exactly what -changes it will make to your home directory before making them. chezmoi's source -format uses only regular files and directories that map one-to-one with the -files, directories, and symlinks in your home directory that you choose to -manage. If you decide not to use chezmoi in the future, it is easy to move your -data elsewhere. - -### Declarative and robust - -You declare the desired state of files, directories, and symbolic links in your -source of truth and chezmoi updates your home directory to match that state. -What you want is what you get. chezmoi updates all files and symbolic links -atomically. You will never be left with incomplete files that could lock you -out, even if the update process is interrupted. - -### Fast and easy to use - -Using chezmoi feels like using git: the commands are similar and chezmoi runs in -fractions of a second. chezmoi makes most day-to-day operations one line -commands, including installation, initialization, and keeping your machines -up-to-date. chezmoi can pull and apply changes from your dotfiles repo in a -single command, and automatically commit and push changes. - ---- - -## I already have a system to manage my dotfiles, why should I use chezmoi? - -Read the [comparison of chezmoi to other dotfile managers](docs/COMPARISON.md). - ---- - -## How do people use chezmoi? - -### Dotfile repos using chezmoi - -Have a look at people using chezmoi [on -GitHub](https://github.com/topics/chezmoi?o=desc&s=updated) and [on -GitLab](https://gitlab.com/explore/projects?topic=chezmoi). - -### People talking about chezmoi - -Read what [people have said about chezmoi](docs/MEDIA.md). - ---- - -## What documentation is available? - -* [Install guide](docs/INSTALL.md) to get chezmoi installed on your machine with - one or two commands. -* [Quick start guide](docs/QUICKSTART.md) for your first steps. -* [How-to guide](docs/HOWTO.md) for achieving specific tasks. -* [Templating guide](docs/TEMPLATING.md) for working with templates. -* [FAQ](docs/FAQ.md) for questions that aren't answered elsewhere. -* [Changes guide](docs/CHANGES.md) for upgrading from a previous major version - of chezmoi. -* [Reference](docs/REFERENCE.md) for a complete description of chezmoi. -* [Comparison guide](docs/COMPARISON.md) for a comparison with other dotfile managers. -* [Related software](docs/RELATED.md) for third party software that works with - chezmoi. -* [Contributing](docs/CONTRIBUTING.md) and [Architecture](docs/ARCHITECTURE.md) - for people looking to contribute to or package chezmoi. - ---- + + + ## License MIT - ---- diff --git a/assets/.gitignore b/assets/.gitignore new file mode 100644 index 00000000000..898765473d1 --- /dev/null +++ b/assets/.gitignore @@ -0,0 +1,6 @@ +__pycache__ + +.venv +.virtualenv +venv +virtualenv diff --git a/assets/chezmoi.io/.gitignore b/assets/chezmoi.io/.gitignore index 45d8a3ae613..569b973d131 100644 --- a/assets/chezmoi.io/.gitignore +++ b/assets/chezmoi.io/.gitignore @@ -1,5 +1,8 @@ -.hugo_build.lock -/content/docs -!/content/docs/menu -/public -/resources +/docs/index.md +/docs/install.md +/docs/links/articles.md +/docs/links/podcasts.md +/docs/links/videos.md +/docs/reference/configuration-file/variables.md +/docs/reference/release-history.md +/site diff --git a/assets/chezmoi.io/CNAME b/assets/chezmoi.io/CNAME new file mode 100644 index 00000000000..55eaa1b0fca --- /dev/null +++ b/assets/chezmoi.io/CNAME @@ -0,0 +1 @@ +www.chezmoi.io diff --git a/assets/chezmoi.io/Makefile b/assets/chezmoi.io/Makefile deleted file mode 100644 index c76faba781d..00000000000 --- a/assets/chezmoi.io/Makefile +++ /dev/null @@ -1,62 +0,0 @@ -.PHONY: website -website: content-docs - ./make-gh-pages.sh - -.PHONY: serve -serve: - hugo serve - -.PHONY: content-docs -content-docs: \ - content/docs/architecture.md \ - content/docs/changes.md \ - content/docs/comparison.md \ - content/docs/contributing.md \ - content/docs/faq.md \ - content/docs/how-to.md \ - content/docs/install.md \ - content/docs/media.md \ - content/docs/quick-start.md \ - content/docs/reference.md \ - content/docs/related.md \ - content/docs/security.md \ - content/docs/templating.md - -content/docs/architecture.md: ../../docs/ARCHITECTURE.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="Architecture" -longtitle="Architecture" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/changes.md: ../../docs/CHANGES.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="Changes" -longtitle="Changes" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/comparison.md: ../../docs/COMPARISON.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="Comparison" -longtitle="Comparison Guide" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/contributing.md: ../../docs/CONTRIBUTING.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="Contributing" -longtitle="Contributing Guide" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/faq.md: ../../docs/FAQ.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="FAQ" -longtitle="Frequently Asked Questions" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/how-to.md: ../../docs/HOWTO.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="How-To" -longtitle="How-To Guide" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/install.md: ../../docs/INSTALL.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="Install" -longtitle="Install Guide" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/media.md: ../../docs/MEDIA.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="Media" -longtitle="Media" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/quick-start.md: ../../docs/QUICKSTART.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="Quick Start" -longtitle="Quick Start Guide" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/reference.md: ../../docs/REFERENCE.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="Reference" -longtitle="Reference Manual" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/related.md: ../../docs/RELATED.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="Related" -longtitle="Related Software" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/security.md: ../../docs/SECURITY.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="Security" -longtitle="Security" < $< > $@ || ( rm -f $@ ; false ) - -content/docs/templating.md: ../../docs/TEMPLATING.md ../../internal/cmds/generate-chezmoi.io-content-docs/main.go Makefile - go run ../../internal/cmds/generate-chezmoi.io-content-docs -shorttitle="Templating" -longtitle="Templating Guide" < $< > $@ || ( rm -f $@ ; false ) diff --git a/assets/chezmoi.io/archetypes/default.md b/assets/chezmoi.io/archetypes/default.md deleted file mode 100644 index 26f317f303e..00000000000 --- a/assets/chezmoi.io/archetypes/default.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: "{{ replace .Name "-" " " | title }}" -date: {{ .Date }} -draft: true ---- diff --git a/assets/chezmoi.io/config.toml b/assets/chezmoi.io/config.toml deleted file mode 100644 index 69d99884e56..00000000000 --- a/assets/chezmoi.io/config.toml +++ /dev/null @@ -1,11 +0,0 @@ -baseURL = "https://chezmoi.io/" -languageCode = "en-us" -theme = "book" -title = "chezmoi.io" - -[params] - BookDateFormat = "2006-02-01" - BookLogo = "logo-144px.svg" - BookMenuBundle = "/menu" - BookRepo = "https://github.com/twpayne/chezmoi" - BookToC = 3 diff --git a/assets/chezmoi.io/content/_index.md b/assets/chezmoi.io/content/_index.md deleted file mode 100644 index 347cf38f656..00000000000 --- a/assets/chezmoi.io/content/_index.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -title: Home -type: docs ---- - -# chezmoi - -Manage your dotfiles across multiple diverse machines, securely. - -With chezmoi, you can install chezmoi and your dotfiles on a new, empty machine -with a single command: - -```console -$ sh -c "$(curl -fsLS git.io/chezmoi)" -- init --apply -``` - -Updating your dotfiles on any machine is a single command: - -```console -$ chezmoi update -``` - ---- - -## How do I start with chezmoi now? - -[Install chezmoi](/docs/install/) then read the [quick start -guide](/docs/quick-start/). The [how-to guide](/docs/how-to/) covers most common -tasks, and there's both documentation on [templating](/docs/templating/) and -[frequently asked questions](/docs/faq/) for specific questions. You can browse -other people's [dotfiles that use chezmoi on -GitHub](https://github.com/topics/chezmoi?o=desc&s=updated) and [dotfiles that -use chezmoi on GitLab](https://gitlab.com/explore/projects?topic=chezmoi), and -see how chezmoi [compares to other dotfile managers](/docs/comparison/). For a -full description of chezmoi, consult the [reference](/docs/reference/). - ---- - -## What does chezmoi do and why should I use it? - -chezmoi helps you manage your personal configuration files (dotfiles, like -`~/.gitconfig`) across multiple machines. - -chezmoi is helpful if you have spent time customizing the tools you use (e.g. -shells, editors, and version control systems) and want to keep machines running -different accounts (e.g. home and work) and/or different operating systems (e.g. -Linux, macOS, and Windows) in sync, while still being able to easily cope with -differences from machine to machine. - -chezmoi scales from the trivial (e.g. copying a few dotfiles onto a Raspberry -Pi, development container, or virtual machine) to complex long-lived -multi-machine development environments (e.g. keeping any number of home and -work, Linux, macOS, and Windows machines in sync). In all cases you only need to -maintain a single source of truth (a single branch in git) and getting started -only requires adding a single binary to your machine (which you can do with -`curl`, `wget`, or `scp`). - -chezmoi has strong support for security, allowing you to manage secrets (e.g. -passwords, access tokens, and private keys) securely and seamlessly using a -password manager and/or encrypt whole files with your favorite encryption tool. - -If you do not personalize your configuration or only ever use a single operating -system with a single account and none of your dotfiles contain secrets then you -don't need chezmoi. Otherwise, read on... - ---- - -## What are chezmoi's key features? - -### Flexible - -You can share as much configuration across machines as you want, while still -being able to control machine-specific details.Your dotfiles can be templates -(using [`text/template`](https://pkg.go.dev/text/template) syntax). Predefined -variables allow you to change behavior depending on operating system, -architecture, and hostname. chezmoi runs on all commonly-used platforms, like -Linux, macOS, and Windows. It also runs on less commonly-used platforms, like -FreeBSD, OpenBSD, and Termux. - -### Personal and secure - -Nothing leaves your machine, unless you want it to. Your configuration remains -in a git repo under your control. You can write the configuration file in the -format of your choice. chezmoi can retrieve secrets from -[1Password](https://1password.com/), [Bitwarden](https://bitwarden.com/), -[gopass](https://www.gopass.pw/), [KeePassXC](https://keepassxc.org/), -[LastPass](https://lastpass.com/), [pass](https://www.passwordstore.org/), -[Vault](https://www.vaultproject.io/), Keychain, -[Keyring](https://wiki.gnome.org/Projects/GnomeKeyring), or any command-line -utility of your choice. You can encrypt individual files with -[GnuPG](https://www.gnupg.org) or [age](https://age-encryption.org). You can -checkout your dotfiles repo on as many machines as you want without revealing -any secrets to anyone. - -### Transparent - -chezmoi includes verbose and dry run modes so you can review exactly what -changes it will make to your home directory before making them. chezmoi's source -format uses only regular files and directories that map one-to-one with the -files, directories, and symlinks in your home directory that you choose to -manage. If you decide not to use chezmoi in the future, it is easy to move your -data elsewhere. - -### Declarative and robust - -You declare the desired state of files, directories, and symbolic links in your -source of truth and chezmoi updates your home directory to match that state. -What you want is what you get. chezmoi updates all files and symbolic links -atomically. You will never be left with incomplete files that could lock you -out, even if the update process is interrupted. - -### Fast and easy to use - -Using chezmoi feels like using git: the commands are similar and chezmoi runs in -fractions of a second. chezmoi makes most day-to-day operations one line -commands, including installation, initialization, and keeping your machines -up-to-date. chezmoi can pull and apply changes from your dotfiles repo in a -single command, and automatically commit and push changes. - ---- - -## I already have a system to manage my dotfiles, why should I use chezmoi? - -Read the [comparison of chezmoi to other dotfile managers](/docs/comparison/). - ---- - -## How do people use chezmoi? - -### Dotfile repos using chezmoi - -Have a look at people using chezmoi [on -GitHub](https://github.com/topics/chezmoi?o=desc&s=updated) and [on -GitLab](https://gitlab.com/search?search=chezmoi). - -### People talking about chezmoi - -Read what [people have said about chezmoi](/docs/media/). - ---- - -## What documentation is available? - -* [Install guide](/docs/install/) to get chezmoi installed on your machine with - one or two commands. -* [Quick start guide](/docs/quick-start/) for your first steps. -* [How-to guide](/docs/how-to/) for achieving specific tasks. -* [Templating guide](/docs/templating/) for working with templates. -* [FAQ](/docs/faq/) for questions that aren't answered elsewhere. -* [Changes guide](/docs/changes/) for upgrading from a previous major version of - chezmoi. -* [Reference](/docs/reference/) for a complete description of chezmoi. -* [Comparison guide](/docs/comparison/) for a comparison with other dotfile managers. -* [Related software](/docs/related/) for third party software that works with - chezmoi. -* [Contributing](/docs/contributing/) and [Architecture](/docs/architecture/) - for people looking to contribute to or package chezmoi. - ---- - -## License - -MIT - ---- diff --git a/assets/chezmoi.io/content/docs/menu/index.md b/assets/chezmoi.io/content/docs/menu/index.md deleted file mode 100644 index 7199085ca71..00000000000 --- a/assets/chezmoi.io/content/docs/menu/index.md +++ /dev/null @@ -1,18 +0,0 @@ -+++ -headless = true -+++ - -- [Install]({{< relref "/docs/install.md" >}}) -- [Quick Start]({{< relref "/docs/quick-start.md" >}}) -- [How-To]({{< relref "/docs/how-to.md" >}}) -- [Templating]({{< relref "/docs/templating.md" >}}) -- [FAQ]({{< relref "/docs/faq.md" >}}) -- [Changes]({{< relref "/docs/changes.md" >}}) -- [Reference]({{< relref "/docs/reference.md" >}}) -- [Media]({{< relref "/docs/media.md" >}}) -- [Comparison]({{< relref "/docs/comparison.md" >}}) -- [Contributing]({{< relref "/docs/contributing.md" >}}) -- [Architecture]({{< relref "/docs/architecture.md" >}}) -- [Related Software]({{< relref "/docs/related.md" >}}) -- [Security]({{< relref "/docs/security.md" >}}) -- [GitHub](https://github.com/twpayne/chezmoi) diff --git a/assets/chezmoi.io/content/logo-144px.svg b/assets/chezmoi.io/content/logo-144px.svg deleted file mode 100644 index 1f97a79a04d..00000000000 --- a/assets/chezmoi.io/content/logo-144px.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/chezmoi.io/docs/comparison-table.md b/assets/chezmoi.io/docs/comparison-table.md new file mode 100644 index 00000000000..eed302fb766 --- /dev/null +++ b/assets/chezmoi.io/docs/comparison-table.md @@ -0,0 +1,42 @@ +# Comparison table + +[chezmoi]: https://chezmoi.io/ +[dotbot]: https://github.com/anishathalye/dotbot +[rcm]: https://github.com/thoughtbot/rcm +[vcsh]: https://github.com/RichiH/vcsh +[yadm]: https://yadm.io/ +[bare git]: https://www.atlassian.com/git/tutorials/dotfiles "bare git" + +| | [chezmoi] | [dotbot] | [rcm] | [vcsh] | [yadm] | [bare git] | +| -------------------------------------- | ------------- | ----------------- | ----------------- | ------------------------ | ---------------------------- | ---------- | +| Distribution | Single binary | Python package | Multiple files | Single script or package | Single script | - | +| Install method | Many | git submodule | Many | Many | Many | Manual | +| Non-root install on bare system | ✅ | ⁉️ | ✅ | ✅ | ✅ | ✅ | +| Windows support | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | +| Bootstrap requirements | None | Python, git | Perl | sh, git | git | git | +| Source repos | Single | Single | Multiple | Multiple | Single | Single | +| dotfiles are... | Files | Symlinks | Files | Files | Files | Files | +| Config file | Optional | Required | Optional | None | Optional | Optional | +| Private files | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | +| Show differences without applying | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | +| Whole file encryption | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | +| Password manager integration | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Machine-to-machine file differences | Templates | Alternative files | Alternative files | Branches | Alternative files, templates | ⁉️ | +| Custom variables in templates | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Executable files | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| File creation with initial contents | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | +| Externals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Manage partial files | ✅ | ❌ | ❌ | ⁉️ | ✅ | ⁉️ | +| File removal | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | +| Directory creation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Run scripts | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| Run once scripts | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | +| Machine-to-machine symlink differences | ✅ | ❌ | ❌ | ⁉️ | ✅ | ⁉️ | +| Shell completion | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | +| Archive import | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | +| Archive export | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | +| Implementation language | Go | Python | Perl | POSIX Shell | Bash | C | + +✅ Supported, ⁉️ Possible with significant manual effort, ❌ Not supported + +For more comparisons, visit [dotfiles.github.io/utilities](https://dotfiles.github.io/utilities/). diff --git a/docs/ARCHITECTURE.md b/assets/chezmoi.io/docs/developer-guide/architecture.md similarity index 63% rename from docs/ARCHITECTURE.md rename to assets/chezmoi.io/docs/developer-guide/architecture.md index 8f1248e5701..07a370921ea 100644 --- a/docs/ARCHITECTURE.md +++ b/assets/chezmoi.io/docs/developer-guide/architecture.md @@ -1,20 +1,4 @@ -# chezmoi architecture guide - - -* [Introduction](#introduction) -* [Directory structure](#directory-structure) -* [Key concepts](#key-concepts) -* [`internal/chezmoi/` directory](#internalchezmoi-directory) -* [`internal/cmd/` directory](#internalcmd-directory) -* [Path handling](#path-handling) -* [Persistent state](#persistent-state) -* [Encryption](#encryption) -* [`run_once_` and `run_onchange_` scripts](#run_once_-and-run_onchange_-scripts) -* [Testing](#testing) - ---- - -## Introduction +# Architecture This document gives a high-level overview of chezmoi's source code for anyone interested in contributing to chezmoi. @@ -27,52 +11,45 @@ $ go doc -all -u github.com/twpayne/chezmoi/v2/internal/chezmoi ``` You can also [browse chezmoi's generated documentation -online](https://pkg.go.dev/github.com/twpayne/chezmoi/v2) but this only includes -exported symbols. - ---- +online](https://pkg.go.dev/github.com/twpayne/chezmoi/v2). ## Directory structure The important directories in chezmoi are: -| Directory | Contents | -| --------- | -------- | -| `docs/` | The documentation single source of truth. Help text, examples, and the [chezmoi.io](https://chezmoi.io) website are generated from the files in this directory, particularly `docs/REFERENCE.md`. | -| `internal/chezmoi/` | chezmoi's core functionality. | -| `internal/cmd/` | Code for the `chezmoi` command. | -| `internal/cmd/testdata/scripts/` | High-level tests of chezmoi's commands using [`testscript`](https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript). | - ---- +| Directory | Contents | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `assets/chezmoi.io/docs/` | The documentation single source of truth. Help text, examples, and the [chezmoi.io](https://chezmoi.io) website are generated from the files in this directory | +| `internal/chezmoi/` | chezmoi's core functionality | +| `internal/cmd/` | Code for the `chezmoi` command | +| `internal/cmd/testdata/scripts/` | High-level tests of chezmoi's commands using [`testscript`](https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript) ## Key concepts -As described in the [reference manual](REFERENCE.md), chezmoi evaluates the -source state to compute a target state for the destination directory (typically -your home directory). It then compares the target state to the actual state of -the destination directory and performs any changes necessary to update the -destination directory to match the target state. The concepts are represented -directly in chezmoi's code. +As described in the [reference manual](../reference/concepts.md), chezmoi +evaluates the source state to compute a target state for the destination +directory (typically your home directory). It then compares the target state to +the actual state of the destination directory and performs any changes necessary +to update the destination directory to match the target state. These concepts +are represented directly in chezmoi's code. chezmoi uses the generic term *entry* to describe something that it manages. Entries can be files, directories, symlinks, scripts, amongst other things. ---- - ## `internal/chezmoi/` directory -All of chezmoi's interaction with the operating system is abstracted through the -`System` interface. A `System` includes functionality to read and write files -and directories and execute commands. chezmoi makes a distinction between +All of chezmoi's interaction with the operating system is abstracted through +the `System` interface. A `System` includes functionality to read and write +files and directories and execute commands. chezmoi makes a distinction between idempotent commands that can be run multiple times without modifying the underlying system and arbitrary commands that may modify the underlying system. The real underlying system is implemented via a `RealSystem` struct. Other `System`s are composed on top of this to provide further functionality. For example, the `--debug` flag is implemented by wrapping the `RealSystem` with a -`DebugSystem` that logs all calls to the underlying `RealSystem`. `--dry-run` is -implemented by wrapping the `RealSystem` with a `DryRunSystem` that allows reads -to pass through but silently discards all writes. +`DebugSystem` that logs all calls to the underlying `RealSystem`. `--dry-run` +is implemented by wrapping the `RealSystem` with a `DryRunSystem` that allows +reads to pass through but silently discards all writes. The `SourceState` struct represents a source state, including reading a source state from the source directory, executing templates, applying the source state @@ -80,8 +57,8 @@ state from the source directory, executing templates, applying the source state entries to the source state. Entries in the source state are abstracted by the `SourceStateEntry` interface -implemented by the `SourceStateFile` and `SourceStateDir` structs, as the source -state only consists of regular files and directories. +implemented by the `SourceStateFile` and `SourceStateDir` structs, as the +source state only consists of regular files and directories. A `SourceStateFile` includes a `FileAttr` struct describing the attributes parsed from its file name. Similarly, a `SourceStateDir` includes a `DirAttr` @@ -101,25 +78,26 @@ The actual state of an entry in the target state is abstracted via the `ActualStateFile`, `ActualStateSymlink` structs implementing this interface. Finally, an `EntryState` struct represents a serialization of an -`ActualEntryState` for storage in and retrieval from chezmoi's persistent state. -It stores a SHA256 of the entry's contents, rather than the full contents, to -avoid storing secrets in the persistent state. +`ActualEntryState` for storage in and retrieval from chezmoi's persistent +state. It stores a SHA256 of the entry's contents, rather than the full +contents, to avoid storing secrets in the persistent state. With these concepts, chezmoi's apply command is effectively: + 1. Read the source state from the source directory. + 2. For each entry in the source state (`SourceStateEntry`), compute its `TargetStateEntry` and read its actual state in the destination state (`ActualStateEntry`). + 3. If the `ActualStateEntry` is not equivalent to the `TargetStateEntry` then apply the minimal set of changes to the `ActualStateEntry` so that they are equivalent. -Furthermore, chezmoi stores the `EntryState` of each entry that it writes in its -persistent state. chezmoi can then detect if a third party has updated a target -since chezmoi last wrote it by comparing the actual state entry in the target -state with the entry state in the persistent state. - ---- +Furthermore, chezmoi stores the `EntryState` of each entry that it writes in +its persistent state. chezmoi can then detect if a third party has updated a +target since chezmoi last wrote it by comparing the actual state entry in the +target state with the entry state in the persistent state. ## `internal/cmd/` directory @@ -132,9 +110,8 @@ line arguments, and computed and cached values. The `Config.persistentPreRunRootE` and `Config.persistentPostRunRootE` methods set up and tear down state for individual commands based on the command's -`Annotations` field. - ---- +`Annotations` field, which defines how the command interacts with the file +system and persistent state. ## Path handling @@ -143,15 +120,13 @@ chezmoi uses separate types for absolute paths (`AbsPath`) and relative paths paths). A further type `SourceRelPath` is a relative path within the source directory and handles file and directory attributes. -Internally, chezmoi normalizes all paths to use forward slashes with an optional -upper-cased Windows volume so they can be compared with string comparisons. -Paths read from the user may include tilde (`~`) to represent the user's home -directory, use forward or backward slashes, and are treated as external paths -(`ExtPath`). These are normalized to absolute paths. chezmoi is case-sensitive -internally and makes no attempt to handle case-insensitive or case-preserving -filesystems. - ---- +Internally, chezmoi normalizes all paths to use forward slashes with an +optional upper-cased Windows volume so they can be compared with string +comparisons. Paths read from the user may include tilde (`~`) to represent the +user's home directory, use forward or backward slashes, and are treated as +external paths (`ExtPath`). These are normalized to absolute paths. chezmoi is +case-sensitive internally and makes no attempt to handle case-insensitive or +case-preserving filesystems. ## Persistent state @@ -163,8 +138,6 @@ mode (`--dry-run`) the actual persistent state is copied into a temporary persistent state in memory which remembers writes but does not persist them to disk. ---- - ## Encryption Encryption tools are abstracted by the `Encryption` interface that contains @@ -172,8 +145,6 @@ methods of encrypting and decrypting files and `[]byte`s. Implementations are the `AGEEncryption` and `GPGEncryption` structs. A `DebugEncryption` struct wraps an `Encryption` interface and logs the methods called. ---- - ## `run_once_` and `run_onchange_` scripts The execution of a `run_once_` script is recorded by storing the SHA256 of its @@ -181,31 +152,28 @@ contents in the `scriptState` bucket in the persistent state. On future invocations the script is only run if no matching contents SHA256 is found in the persistent state. -The execution of a `run_onchange_` script is recorded by storing its target name -in the `entryState` bucket along with its contents SHA256 sum. On future +The execution of a `run_onchange_` script is recorded by storing its target +name in the `entryState` bucket along with its contents SHA256 sum. On future invocations the script is only run if its contents SHA256 sum has changed, and its contents SHA256 sum is then updated in the persistent state. ---- - ## Testing chezmoi has a mix of, unit, integration, and end-to-end tests. Unit and integration tests use the -[`github.com/stretchr/testify`](https://pkg.go.dev/github.com/stretchr/testify) +[`github.com/alecthomas/assert/v2`](https://pkg.go.dev/github.com/alecthomas/assert) framework. End-to-end tests use [`github.com/rogpeppe/go-internal/testscript`](https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript) -with the test scripts themselves in `testdata/scripts`. You can run individual -end-to-end tests with +with the test scripts themselves in +`internal/cmd/testdata/scripts/$TEST_NAME.txtar`. + +You can run individual end-to-end tests with ```console -$ go test ./internal/cmd -run=TestScript/ +$ go test ./internal/cmd -run=TestScript/$TEST_NAME ``` -where `` is the basename of file in `testdata/scripts`. - -Tests should, if at all possible, run unmodified on all operating systems tested -in CI (Linux, macOS, Windows, and FreeBSD). Windows will sometimes need special -handling due to its path separator and lack of POSIX-style file permissions. - ---- +Tests should, if at all possible, run unmodified on all operating systems +tested in CI (Linux, macOS, Windows, and FreeBSD). Windows will sometimes need +special handling due to its path separator and lack of POSIX-style file +permissions. diff --git a/assets/chezmoi.io/docs/developer-guide/building-on-top-of-chezmoi.md b/assets/chezmoi.io/docs/developer-guide/building-on-top-of-chezmoi.md new file mode 100644 index 00000000000..13804c27813 --- /dev/null +++ b/assets/chezmoi.io/docs/developer-guide/building-on-top-of-chezmoi.md @@ -0,0 +1,7 @@ +# Building on top of chezmoi + +chezmoi is designed with UNIX-style composability in mind, and the command line +tool is semantically versioned. Building on top of chezmoi should primarily be +done by executing the binary with arguments and the standard input and output +configured appropriately. The `chezmoi dump` and `chezmoi state` commands +allows the inspection of chezmoi's internal state. diff --git a/assets/chezmoi.io/docs/developer-guide/contributing-changes.md b/assets/chezmoi.io/docs/developer-guide/contributing-changes.md new file mode 100644 index 00000000000..f211cad37aa --- /dev/null +++ b/assets/chezmoi.io/docs/developer-guide/contributing-changes.md @@ -0,0 +1,45 @@ +# Contributing changes + +Bug reports, bug fixes, and documentation improvements are always welcome. +Please [open an issue](https://github.com/twpayne/chezmoi/issues/new/choose) or +[create a pull +request](https://help.github.com/en/articles/creating-a-pull-request) with your +report, fix, or improvement. + +If you want to make a more significant change, please first [open an +issue](https://github.com/twpayne/chezmoi/issues/new/choose) to discuss the +change that you want to make. Dave Cheney gives a [good +rationale](https://dave.cheney.net/2019/02/18/talk-then-code) as to why this is +important. + +All changes are made via pull requests. In your pull request, please make sure +that: + +* All existing tests pass. You can ensure this by running `make test`. + +* There are appropriate additional tests that demonstrate that your PR works as + intended. + +* The documentation is updated, if necessary. For new features you should add an + entry in `assets/chezmoi.io/docs/user-guide/` and a complete description in + `assets/chezmoi.io/docs/reference/`. See [website](website.md) for + instructions on how to build and view a local version of the documentation. + +* All generated files are up to date. You can ensure this by running `make + generate` and including any modified files in your commit. + +* The code is correctly formatted, according to + [`gofumpt`](https://mvdan.cc/gofumpt/). You can ensure this by running `make + format`. + +* The code passes [`golangci-lint`](https://github.com/golangci/golangci-lint). + You can ensure this by running `make lint`. + +* The commit messages follow the [conventional commits + specification](https://www.conventionalcommits.org/en/v1.0.0/). chezmoi's + release notes are generated directly from the commit messages. For trivial or + user-invisible changes, please use the prefix `chore:`. + +* Commits are logically separate, with no merge or "fixup" commits. + +* The branch applies cleanly to `master`. diff --git a/assets/chezmoi.io/docs/developer-guide/index.md b/assets/chezmoi.io/docs/developer-guide/index.md new file mode 100644 index 00000000000..2e9e7288aa3 --- /dev/null +++ b/assets/chezmoi.io/docs/developer-guide/index.md @@ -0,0 +1,61 @@ +# Developer + +chezmoi is written in [Go](https://golang.org) and development happens on +[GitHub](https://github.com). chezmoi is a standard Go project, using standard +Go tooling. chezmoi requires Go 1.21 or later. + +Checkout chezmoi: + +```console +$ git clone https://github.com/twpayne/chezmoi.git +$ cd chezmoi +``` + +Build chezmoi: + +```console +$ go build +``` + +Run all tests: + +```console +$ go test ./... +``` + +chezmoi's tests include integration tests with other software. If the other +software is not found in `$PATH` the tests will be skipped. Running the full set +of tests requires `age`, `base64`, `bash`, `gpg`, `perl`, `python3`, `rage`, +`ruby`, `sed`, `sha256sum`, `unzip`, `xz`, `zip`, and `zstd`. + +Run chezmoi: + +```console +$ go run . +``` + +Run a set of smoketests, including cross-compilation, tests, and linting: + +```console +$ make smoketest +``` + +!!! hint + + If you use `fish` as your primary shell, you may get warnings from Fish + during tests: + + ``` + error: can not save history + warning-path: Unable to locate data directory derived from $HOME: '/home/user/.local/share/fish'. + warning-path: The error was 'Operation not supported'. + warning-path: Please set $HOME to a directory where you have write access. + ``` + + These can be avoided with by running tests with `SHELL=bash` or `SHELL=zsh`: + + ```console + $ SHELL=bash make test + $ SHELL=zsh make smoketest + $ SHELL=bash go test ./... + ``` diff --git a/assets/chezmoi.io/docs/developer-guide/install-script.md b/assets/chezmoi.io/docs/developer-guide/install-script.md new file mode 100644 index 00000000000..cae38714782 --- /dev/null +++ b/assets/chezmoi.io/docs/developer-guide/install-script.md @@ -0,0 +1,19 @@ +# Install script + +chezmoi generates the [install +script](https://github.com/twpayne/chezmoi/blob/master/assets/scripts/install.sh) +from a single source of truth. You must run + +```console +$ go generate +``` + +if your change includes any of the following: + +* Modifications to the install script template. + +* Additions or modifications to the list of supported OSs and architectures. + +chezmoi's continuous integration verifies that all generated files are up to +date. Changes to generated files should be included in the commit that modifies +the source of truth. diff --git a/assets/chezmoi.io/docs/developer-guide/packaging.md b/assets/chezmoi.io/docs/developer-guide/packaging.md new file mode 100644 index 00000000000..2eb3d6ef77f --- /dev/null +++ b/assets/chezmoi.io/docs/developer-guide/packaging.md @@ -0,0 +1,72 @@ +# Packaging + +If you're packaging chezmoi for an operating system or distribution: + +chezmoi has no build dependencies other than the standard Go toolchain. + +chezmoi has no runtime dependencies, but is usually used with `git`, so many +packagers choose to make `git` an install dependency or recommended package. + +Please set the version number, git commit, and build time in the binary. This +greatly assists debugging when end users report problems or ask for help. You +can do this by passing the following flags to `go build`: + +``` +-ldflags "-X main.version=$VERSION + -X main.commit=$COMMIT + -X main.date=$DATE + -X main.builtBy=$BUILT_BY" +``` + +`$VERSION` should be the chezmoi version, e.g. `1.7.3`. Any `v` prefix is +optional and will be stripped, so you can pass the git tag in directly. + +!!! hint + + The command `git describe --abbrev=0 --tags` will return a suitable value + for `$VERSION`. + +`$COMMIT` should be the full git commit hash at which chezmoi is built, e.g. +`4d678ce6850c9d81c7ab2fe0d8f20c1547688b91`. + +!!! hint + + The `assets/scripts/generate-commit.go` script will return a suitable value + for `$COMMIT`. + You can run it with `go run assets/scripts/generate-commit.go`. + +!!! hint + + The source archive contains a file called `COMMIT` containing the commit + hash. + +`$DATE` should be the date of the build as a UNIX timestamp or in RFC3339 +format. + +!!! hint + + The command `git show -s --format=%ct HEAD` returns the UNIX timestamp of + the last commit, e.g. `1636668628`. + + The command `date -u +%Y-%m-%dT%H:%M:%SZ` returns the current time in + RFC3339 format, e.g. `2019-11-23T18:29:25Z`. + +`$BUILT_BY` should be a string indicating what system was used to build the +binary. Typically it should be the name of your packaging system, e.g. +`homebrew`. + +Please enable cgo, if possible. chezmoi can be built and run without cgo, but +the `.chezmoi.username` and `.chezmoi.group` template variables may not be set +correctly on some systems. + +chezmoi includes an `upgrade` command which attempts to self-upgrade. You can +remove this command completely by building chezmoi with the `noupgrade` build +tag. + +chezmoi includes shell completions in the `completions` directory. Please +include these in the package and install them in the shell-appropriate +directory, if possible. + +If the instructions for installing chezmoi in chezmoi's [install +guide](../install.md) are absent or incorrect, please open an issue or submit a +PR to correct them. diff --git a/assets/chezmoi.io/docs/developer-guide/releases.md b/assets/chezmoi.io/docs/developer-guide/releases.md new file mode 100644 index 00000000000..de1f75d4c59 --- /dev/null +++ b/assets/chezmoi.io/docs/developer-guide/releases.md @@ -0,0 +1,93 @@ +# Releases + +Releases are managed with [`goreleaser`](https://goreleaser.com/). + +## Testing + +To build a test release, without publishing, (Ubuntu Linux only) first ensure +that the `musl-tools` and `snapcraft` packages are installed: + +```console +$ sudo apt-get install musl-tools snapcraft +``` + +Then run: + +```console +$ make test-release +``` + +## Publishing + +Publish a new release by creating and pushing a tag, for example: + +```console +$ git tag v1.2.3 +$ git push --tags +``` + +This triggers a [GitHub Action](https://github.com/twpayne/chezmoi/actions) +that builds and publishes archives, packages, and snaps, creates a new [GitHub +Release](https://github.com/twpayne/chezmoi/releases), and deploys the +[website](https://chezmoi.io). + +!!! note + + Publishing [Snaps](https://snapcraft.io/) requires a + `SNAPCRAFT_STORE_CREDENTIALS` [repository + secret](https://github.com/twpayne/chezmoi/settings/secrets/actions). + + Snapcraft store credentials periodically expire. Create new snapcraft store + credentials by running: + + ```console + $ snapcraft export-login --snaps=chezmoi --channels=stable,candidate,beta,edge --acls=package_upload - + ``` + +!!! note + + [brew](https://brew.sh/) automation will automatically detect new releases + of chezmoi within a few hours and open a pull request in + [github.com/Homebrew/homebrew-core](https://github.com/Homebrew/homebrew-core) + to bump the version. + + If needed, the pull request can be created with: + + ```console + $ brew bump-formula-pr --tag=v1.2.3 chezmoi + ``` + +!!! note + + chezmoi is in [Scoop](https://scoop.sh/)'s Main bucket. Scoop's automation + will automatically detect new releases within a few hours. + +## Signing + +chezmoi uses [GoReleaser's support for +signing](https://goreleaser.com/customization/sign/) to sign the checksums of +its release assets with [cosign](https://github.com/sigstore/cosign). + +Details: + +* The cosign private key was generated with cosign v1.12.1 on a private + recently-installed Ubuntu 22.04.1 system with a single user and all available + updates applied. + +* The private key uses a long (more than 32 character) password generated + locally by a password manager. + +* The password-protected private key is stored in chezmoi's public GitHub repo. + +* The private key's password is stored as a [GitHub Actions + secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) + and only available to the `release` step of `release` job of the `main` + workflow. + +* The cosign public key is included in the release assets and also uploaded to + [`https://chezmoi.io/cosign.pub`](https://chezmoi.io/cosign.pub). Since + [`https://chezmoi.io`](https://chezmoi.io) is served by [GitHub + pages](https://pages.github.com/), it probably has equivalent security to + [chezmoi's GitHub Releases + page](https://github.com/twpayne/chezmoi/releases), which is also managed by + GitHub. diff --git a/docs/SECURITY.md b/assets/chezmoi.io/docs/developer-guide/security.md similarity index 53% rename from docs/SECURITY.md rename to assets/chezmoi.io/docs/developer-guide/security.md index a3dc855c5c5..a0dcd2597d0 100644 --- a/docs/SECURITY.md +++ b/assets/chezmoi.io/docs/developer-guide/security.md @@ -1,21 +1,12 @@ -# chezmoi security policy - - -* [Supported versions](#supported-versions) -* [Reporting a vulnerability](#reporting-a-vulnerability) - ---- +# Security ## Supported versions Only the most recent version of chezmoi is supported with security updates. ---- - ## Reporting a vulnerability Please report vulnerabilities by [opening a GitHub -issue](https://github.com/twpayne/chezmoi/issues/new/choose) or sending an email -to twpayne+chezmoi-security@gmail.com. - ---- +issue](https://github.com/twpayne/chezmoi/issues/new/choose) or sending an +email to +[`twpayne+chezmoi-security@gmail.com`](mailto:twpayne%2Bchezmoi-security@gmail.com). diff --git a/assets/chezmoi.io/docs/developer-guide/testing.md b/assets/chezmoi.io/docs/developer-guide/testing.md new file mode 100644 index 00000000000..c93f84d0697 --- /dev/null +++ b/assets/chezmoi.io/docs/developer-guide/testing.md @@ -0,0 +1,24 @@ +# Testing + +chezmoi uses multiple levels of testing: + +1. Unit testing, using [`testing`](https://pkg.go.dev/testing), and + [`github.com/alecthomas/assert/v2`](https://pkg.go.dev/github.com/alecthomas/assert/v2), + tests that functions and small components behave as expected for a wide range + of inputs, especially edge cases. These are generally found in + `internal/chezmoi/*_test.go`. + +2. Filesystem integration tests, using `testing` and + [`github.com/twpayne/go-vfs/v5`](https://pkg.go.dev/github.com/twpayne/go-vfs/v5), + test chezmoi's effects on the filesystem. This include some tests in + `internal/chezmoi/*_test.go`, and higher level command tests in + `internal/cmd/*cmd_test.go`. + +3. High-level integration tests using + [`github.com/rogpeppe/go-internal/testscript`](https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript) + are in `internal/cmd/testdata/scripts/*.txtar` and are run by + `internal/cmd/main_test.go`. + +4. Linux distribution and OS tests run the full test suite using Docker for + different Linux distributions (in `assets/docker`) and Vagrant for different + OSes (in `assets/vagrant`). Windows tests are run in GitHub Actions. diff --git a/assets/chezmoi.io/docs/developer-guide/using-make.md b/assets/chezmoi.io/docs/developer-guide/using-make.md new file mode 100644 index 00000000000..643ae725538 --- /dev/null +++ b/assets/chezmoi.io/docs/developer-guide/using-make.md @@ -0,0 +1,20 @@ +# Building and installing with `make` + +chezmoi can be built with GNU make, assuming you have the Go toolchain +installed. + +Running `make` will build a `chezmoi` binary in the current directory for the +host OS and architecture. To embed version information in the binary and +control installation the following variables are available: + +| Variable | Example | Purpose | +| ----------- | ---------------------- | ---------------------------------------------- | +| `$VERSION` | `v2.0.0` | Set version | +| `$COMMIT` | `3895680a`... | Set the git commit at which the code was built | +| `$DATE` | `2019-11-23T18:29:25Z` | The time of the build | +| `$BUILT_BY` | `homebrew` | The packaging system performing the build | +| `$PREFIX` | `/usr` | Installation prefix | +| `$DESTDIR` | `install-root` | Fake installation root | + +Running `make install` will install the `chezmoi` binary in +`${DESTDIR}${PREFIX}/bin`. diff --git a/assets/chezmoi.io/docs/developer-guide/website.md b/assets/chezmoi.io/docs/developer-guide/website.md new file mode 100644 index 00000000000..abdd59e4a70 --- /dev/null +++ b/assets/chezmoi.io/docs/developer-guide/website.md @@ -0,0 +1,52 @@ +# Website + +The [website](https://chezmoi.io) is generated with [Material for +MkDocs](https://squidfunk.github.io/mkdocs-material/) from the contents of the +`assets/chezmoi.io/docs/` directory. It hosted by [GitHub pages](https://pages.github.com/) from +the [`gh-pages` branch](https://github.com/twpayne/chezmoi/tree/gh-pages). + +Change into the website directory: + +```console +$ cd assets/chezmoi.io +``` + +!!! note "" + + === "Default" + + Install the website dependencies: + + ```console + $ pip3 install --user -r requirements.txt + ``` + + === "virtualenv (Recommended)" + + Create a virtualenv with: + + ```console + $ python3 -m venv .venv + ``` + + and [activate it](https://docs.python.org/3/library/venv.html#how-venvs-work). + + Install the website dependencies: + + ```console + $ pip3 install -r requirements.txt + ``` + +Test the website locally by running: + +```console +$ mkdocs serve +``` + +and visiting [http://127.0.0.1:8000/](http://127.0.0.1:8000/). + +Deploy the website with: + +```console +$ mkdocs gh-deploy +``` diff --git a/assets/chezmoi.io/docs/docs.go b/assets/chezmoi.io/docs/docs.go new file mode 100644 index 00000000000..2bcfa52bf81 --- /dev/null +++ b/assets/chezmoi.io/docs/docs.go @@ -0,0 +1,9 @@ +// Package docs contains chezmoi's documentation. +package docs + +import _ "embed" + +// License is the license. +// +//go:embed license.md +var License []byte diff --git a/assets/chezmoi.io/docs/extra/refresh_on_toggle_dark_light.js b/assets/chezmoi.io/docs/extra/refresh_on_toggle_dark_light.js new file mode 100644 index 00000000000..c72afd7743d --- /dev/null +++ b/assets/chezmoi.io/docs/extra/refresh_on_toggle_dark_light.js @@ -0,0 +1,10 @@ +var paletteSwitcher1 = document.getElementById("__palette_1"); +var paletteSwitcher2 = document.getElementById("__palette_2"); + +paletteSwitcher1.addEventListener("change", function () { + location.reload(); +}); + +paletteSwitcher2.addEventListener("change", function () { + location.reload(); +}); diff --git a/assets/chezmoi.io/docs/hooks.py b/assets/chezmoi.io/docs/hooks.py new file mode 100644 index 00000000000..3d7b84ebd22 --- /dev/null +++ b/assets/chezmoi.io/docs/hooks.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path, PurePosixPath + +from mkdocs import utils +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.structure.files import Files + +non_website_paths = [ + 'docs.go', + 'hooks.py', + 'reference/commands/commands.go', + 'reference/commands/commands_test.go', +] + +templates = [ + 'index.md', + 'install.md', + 'links/articles.md', + 'links/podcasts.md', + 'links/videos.md', + 'reference/configuration-file/variables.md', + 'reference/release-history.md', +] + + +def on_pre_build(config: MkDocsConfig, **kwargs) -> None: + config_dir = Path(config.config_file_path).parent + docs_dir = PurePosixPath(config.docs_dir) + for src_path in templates: + output_path = docs_dir.joinpath(src_path) + template_path = output_path.parent / (output_path.name + '.tmpl') + data_path = output_path.parent / (output_path.name + '.yaml') + args = [ + 'go', + 'run', + Path(config_dir, '../../internal/cmds/execute-template/main.go'), + ] + if Path(data_path).exists(): + args.extend(['-data', data_path]) + args.extend(['-output', output_path, template_path]) + subprocess.run(args, check=False) + + +def on_files(files: Files, config: MkDocsConfig, **kwargs) -> Files: + # remove non-website files + for src_path in non_website_paths: + files.remove(files.get_file_from_path(src_path)) + + # remove templates and data + for src_path in templates: + files.remove(files.get_file_from_path(src_path + '.tmpl')) + data_path = src_path + '.yaml' + if data_path in files: + files.remove(files.get_file_from_path(data_path)) + + return files + + +def on_post_build(config: MkDocsConfig, **kwargs) -> None: + config_dir = Path(config.config_file_path).parent + site_dir = config.site_dir + + # copy GitHub pages config + utils.copy_file(Path(config_dir, 'CNAME'), Path(site_dir, 'CNAME')) + + # copy installation scripts + utils.copy_file(Path(config_dir, '../scripts/install.sh'), Path(site_dir, 'get')) + utils.copy_file( + Path(config_dir, '../scripts/install-local-bin.sh'), + Path(site_dir, 'getlb'), + ) + utils.copy_file( + Path(config_dir, '../scripts/install.ps1'), + Path(site_dir, 'get.ps1'), + ) + + # copy cosign.pub + utils.copy_file( + Path(config_dir, '../cosign/cosign.pub'), + Path(site_dir, 'cosign.pub'), + ) diff --git a/assets/chezmoi.io/docs/index.md.tmpl b/assets/chezmoi.io/docs/index.md.tmpl new file mode 100644 index 00000000000..b85fb9a573f --- /dev/null +++ b/assets/chezmoi.io/docs/index.md.tmpl @@ -0,0 +1,64 @@ +{{- $latestRelease := gitHubLatestRelease "twpayne/chezmoi" -}} +{{- $version := $latestRelease.Name | trimPrefix "v" -}} +# chezmoi + +Manage your dotfiles across multiple diverse machines, securely. + +The latest version of chezmoi is {{ $version }} ([release notes]({{ +$latestRelease.HTMLURL }}), [release history](reference/release-history.md)). + +chezmoi helps you manage your personal configuration files (dotfiles, like +`~/.gitconfig`) across multiple machines. + +chezmoi provides many features beyond symlinking or using a bare git repo +including: templates (to handle small differences between machines), password +manager support (to store your secrets securely), importing files from archives +(great for shell and editor plugins), full file encryption (using gpg or age), +and running scripts (to handle everything else). + +With chezmoi, pronounced /ʃeɪ mwa/ (shay-moi), you can install chezmoi and your +dotfiles from your GitHub dotfiles repo on a new, empty machine with a single +command: + +```console +$ sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply $GITHUB_USERNAME +``` + +As well as the `curl | sh` installation, you can [install chezmoi with your +favorite package manager](install.md). + +Updating your dotfiles on any machine is a single command: + +```console +$ chezmoi update +``` + +## How do I start with chezmoi? + +[Install chezmoi](install.md) then read the [quick start guide](quick-start.md). +The [user guide](user-guide/setup.md) covers most common tasks. For a full +description, consult the [reference](reference/index.md). + +## Should I use chezmoi? + +See what other people think about chezmoi by reading +[articles](links/articles.md), listening to [podcasts](links/podcasts.md), and +watching [videos](links/videos.md) about chezmoi. Read how [chezmoi compares to +other dotfile managers](comparison-table.md). Explore other people's [dotfile +repos that use chezmoi](links/dotfile-repos.md). + +## I like chezmoi. How do I say thanks? + +Please [give chezmoi a star on +GitHub](https://github.com/twpayne/chezmoi/stargazers). + +[Share chezmoi](links/social-media.md) and, if you're happy to share your public +dotfiles repo, then [tag your repo with `chezmoi`](links/dotfile-repos.md). + +[Contributions are very welcome](developer-guide/contributing-changes.md) and +every [bug report, support request, and feature +request](https://github.com/twpayne/chezmoi/issues/new/choose) helps make +chezmoi better. Thank you :) + +chezmoi does not accept financial contributions. Instead, please make a donation +to a charity or cause of your choice. diff --git a/assets/chezmoi.io/docs/install.md.tmpl b/assets/chezmoi.io/docs/install.md.tmpl new file mode 100644 index 00000000000..8374cf2e4e3 --- /dev/null +++ b/assets/chezmoi.io/docs/install.md.tmpl @@ -0,0 +1,319 @@ +{{- $latestRelease := gitHubLatestRelease "twpayne/chezmoi" -}} +{{- $version := $latestRelease.Name | trimPrefix "v" -}} +# Install + +The latest version of chezmoi is {{ $version }} ([release notes]({{ +$latestRelease.HTMLURL }}), [release history](reference/release-history.md)). + +## One-line package install + +Install chezmoi with your package manager with a single command: + +=== "Linux" + + === "Alpine" + + ```sh + apk add chezmoi + ``` + + === "Arch" + + ```sh + pacman -S chezmoi + ``` + + === "NixOS" + + ```sh + nix-env -i chezmoi + ``` + + === "openSUSE Tumbleweed" + + ```sh + zypper install chezmoi + ``` + + === "Termux" + + ```sh + pkg install chezmoi + ``` + + === "Void" + + ```sh + xbps-install -S chezmoi + ``` + +=== "macOS" + + === "Homebrew" + + ```sh + brew install chezmoi + ``` + + === "MacPorts" + + ```sh + port install chezmoi + ``` + + === "Nix" + + ```sh + nix-env -i chezmoi + ``` + +=== "Windows" + + === "Chocolatey" + + ``` + choco install chezmoi + ``` + + === "Scoop" + + ``` + scoop install chezmoi + ``` + + === "Winget" + + ``` + winget install twpayne.chezmoi + ``` + +=== "FreeBSD" + + ```sh + pkg install chezmoi + ``` + +=== "OpenIndiana" + + ```sh + pkg install application/chezmoi + ``` + +chezmoi is available in many cross-platform package managers: + +=== "asdf" + + ```sh + asdf plugin add chezmoi && asdf install chezmoi {{ $version }} + ``` + +=== "Homebrew" + + ```sh + brew install chezmoi + ``` + +=== "Nix" + + ```sh + nix-env -i chezmoi + ``` + +=== "snap" + + ```sh + snap install chezmoi --classic + ``` + +For more packages, see [chezmoi on +repology.org](https://repology.org/project/chezmoi/versions). + +## One-line binary install + +Install the correct binary for your operating system and architecture in `./bin` +with a single command: + +=== "curl" + + ```sh + sh -c "$(curl -fsLS get.chezmoi.io)" + ``` + +=== "wget" + + ```sh + sh -c "$(wget -qO- get.chezmoi.io)" + ``` + +=== "PowerShell" + + ```powershell + iex "&{$(irm 'https://get.chezmoi.io/ps1')}" + ``` + + To provide the script with arguments, place them at the end of the quote: + + ```powershell + iex "&{$(irm 'https://get.chezmoi.io/ps1')} -b '~/bin'" + ``` + +!!! hint + + If you already have a dotfiles repo using chezmoi on GitHub at + `https://github.com/$GITHUB_USERNAME/dotfiles` then you can install + chezmoi and your dotfiles with the single command: + + ```sh + sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply $GITHUB_USERNAME + ``` + + Private GitHub repos require other + [authentication methods](https://docs.github.com/en/get-started/getting-started-with-git/about-remote-repositories#cloning-with-https-urls): + + ```sh + sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply git@github.com:$GITHUB_USERNAME/dotfiles.git + ``` + +!!! hint + + If you want to install chezmoi in `./.local/bin` instead of `./bin` you can + use `get.chezmoi.io/lb` or `chezmoi.io/getlb` instead. + +!!! hint + + To install the chezmoi binary in a different directory, use the `-b` option, + for example: + + ```sh + sh -c "$(curl -fsLS get.chezmoi.io)" -- -b $HOME/.local/bin + ``` + +## Download a pre-built Linux package + +Download a package for your distribution and architecture. + +=== "deb" + +{{ range $arch := list "amd64" "arm64" "armel" "i386" "loong64" "mips64" "mips64le" "ppc64" "ppc64le" "riscv64" "s390x" }} + [`{{ $arch }}`](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_linux_{{ $arch }}.deb) +{{- end }} + +=== "rpm" + +{{ range $arch := list "aarch64" "armhfp" "i686" "loong64" "mips64" "mips64le" "ppc64" "ppc64le" "s390x" "riscv64" "x86_64" }} + [`{{ $arch }}`](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi-{{ $version }}-{{ $arch }}.rpm) +{{- end }} + +=== "apk" + +{{ range $arch := list "386" "amd64" "arm" "arm64" "loong64" "mips64_hardfloat" "mips64le_hardfloat" "ppc64" "ppc64le" "riscv64" "s390x" }} + [`{{ $arch }}`](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_linux_{{ $arch }}.apk) +{{- end }} + +=== "Arch Linux" + +{{ range $arch := list "386" "amd64" "arm" "arm64" "loong64" "mips64_hardfloat" "mips64le_hardfloat" "ppc64" "ppc64le" "riscv64" "s390x" }} + [`{{ $arch }}`](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_linux_{{ $arch }}.pkg.tar.zst) +{{- end }} + +## Download a pre-built binary + +Download an archive for your operating system and architecture containing a +pre-built binary and shell completions. + +=== "Linux" + +{{ range $arch := list "amd64" "arm" "arm64" "i386" "loong64" "mips64" "mips64le" "ppc64" "ppc64le" "riscv64" "s390x" }} + [`{{ $arch }}`](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_linux_{{ $arch }}.tar.gz) +{{- end }} + [`amd64` (glibc)](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_linux-glibc_amd64.tar.gz) + [`amd64` (musl)](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_linux-musl_amd64.tar.gz) + [`arm64` (Termux)](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_android_arm64.tar.gz) + +=== "macOS" + +{{ range $arch := list "amd64" "arm64" }} + [`{{ $arch }}`](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_darwin_{{ $arch }}.tar.gz) +{{- end }} + +=== "Windows" + +{{ range $arch := list "amd64" "arm" "arm64" "i386" }} + [`{{ $arch }}`](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_windows_{{ $arch }}.zip) +{{- end }} + +=== "FreeBSD" + +{{ range $arch := list "amd64" "arm" "arm64" "i386" "riscv64" }} + [`{{ $arch }}`](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_freebsd_{{ $arch }}.tar.gz) +{{- end }} + +=== "OpenBSD" + +{{ range $arch := list "amd64" "arm" "arm64" "i386" "ppc64" }} + [`{{ $arch }}`](https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_openbsd_{{ $arch }}.tar.gz) +{{- end }} + +## Install from source + +Download, build, and install chezmoi for your system with Go 1.21 or later: + +```console +$ git clone https://github.com/twpayne/chezmoi.git +$ cd chezmoi +$ make install-from-git-working-copy +``` + +## Verify your download + +chezmoi's release process signs the SHA256 checksums of [all released +assets](https://github.com/twpayne/chezmoi/releases/tag/v{{ $version }}) with +[cosign](https://github.com/SigStore/cosign). + +To verify an asset that you have downloaded: + +Download the [checksum +file](https://github.com/twpayne/chezmoi/releases/download/v{{ $version +}}/chezmoi_{{ $version }}_checksums.txt), [checksum file +signature](https://github.com/twpayne/chezmoi/releases/download/v{{ $version +}}/chezmoi_{{ $version }}_checksums.txt.sig), and [public signing +key](https://github.com/twpayne/chezmoi/releases/download/v{{ $version +}}/chezmoi_cosign.pub). + + ```console + $ curl --location --remote-name-all \ + https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_checksums.txt \ + https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_{{ $version }}_checksums.txt.sig \ + https://github.com/twpayne/chezmoi/releases/download/v{{ $version }}/chezmoi_cosign.pub + ``` + +Verify the signature of the checksum file with cosign. + + ```console + $ cosign verify-blob --key=chezmoi_cosign.pub \ + --signature=chezmoi_{{ $version }}_checksums.txt.sig \ + chezmoi_{{ $version }}_checksums.txt + ``` + +!!! important + + cosign should print `Verified OK` + +Verify the that the SHA256 sum of your downloads matches the SHA256 sum in the +verified checksum file. All the downloaded files must be in the current +directory. + +=== "Linux" + + ```console + $ sha256sum --check chezmoi_{{ $version }}_checksums.txt --ignore-missing + ``` + +=== "macOS" + + ```console + $ shasum --algorithm 256 --check chezmoi_{{ $version }}_checksums.txt --ignore-missing + ``` + +For more information on chezmoi's release signing process, see the [developer +documentation on chezmoi's releases](developer-guide/releases.md). diff --git a/assets/chezmoi.io/docs/license.md b/assets/chezmoi.io/docs/license.md new file mode 100644 index 00000000000..712852afa3e --- /dev/null +++ b/assets/chezmoi.io/docs/license.md @@ -0,0 +1,23 @@ +# License + +The MIT License (MIT) + +Copyright (c) 2018-2024 Tom Payne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/assets/chezmoi.io/docs/links/articles.md.tmpl b/assets/chezmoi.io/docs/links/articles.md.tmpl new file mode 100644 index 00000000000..9f00ff68479 --- /dev/null +++ b/assets/chezmoi.io/docs/links/articles.md.tmpl @@ -0,0 +1,11 @@ +# Articles + +!!! tip + + Recommended article: [Fedora Magazine: Take back your dotfiles with Chezmoi](https://fedoramagazine.org/take-back-your-dotfiles-with-chezmoi/) + +| Date | Version | Language | Title | +| ---- | ------- | -------- | ----- | +{{- range mustReverse .articles }} +| {{ .date }} | {{ .version }} | {{ index . "lang" | default "EN" }} | [{{ .title }}]({{ .url }}) | +{{- end }} diff --git a/assets/chezmoi.io/docs/links/articles.md.yaml b/assets/chezmoi.io/docs/links/articles.md.yaml new file mode 100644 index 00000000000..16280346aad --- /dev/null +++ b/assets/chezmoi.io/docs/links/articles.md.yaml @@ -0,0 +1,427 @@ +articles: +- date: '2019-01-10' + version: 0.0.11 + title: 'Linux Fu: The kitchen sync' + url: https://hackaday.com/2019/01/10/linux-fu-the-kitchen-sync/ +- date: '2020-04-01' + version: 1.7.17 + title: Managing dotfiles and secret with chezmoi + url: https://blog.arkey.fr/2020/04/01/manage_dotfiles_with_chezmoi/ +- date: '2020-04-03' + version: 1.7.17 + title: 'Fedora Magazine: Take back your dotfiles with Chezmoi' + url: https://fedoramagazine.org/take-back-your-dotfiles-with-chezmoi/ +- date: '2020-04-17' + version: 1.7.17 + lang: CN + title: 用 Chezmoi 取回你的点文件 + url: https://blog.csdn.net/F8qG7f9YD02Pe/article/details/105548429 +- date: '2020-04-16' + version: 1.7.19 + lang: FR + title: Chezmoi, visite guidée + url: https://blog.wescale.fr/2020/04/16/chezmoi-visite-guidee/ +- date: '2020-04-19' + version: 1.7.19 + lang: FR + title: 'Git & dotfiles : versionner ses fichiers de configuration' + url: https://www.armandphilippot.com/dotfiles-git-fichiers-configuration/ +- date: '2020-04-20' + version: 1.8.0 + lang: FR + title: Gestion des dotfiles et des secrets avec chezmoi + url: https://blog.arkey.fr/2020/04/01/manage_dotfiles_with_chezmoi.fr/ +- date: '2020-04-27' + version: 1.8.0 + title: Managing my dotfiles with chezmoi + url: http://blog.emilieschario.com/post/managing-my-dotfiles-with-chezmoi/ +- date: '2020-06-15' + version: 1.8.2 + title: Dotfiles management using chezmoi - How I Use Linux Desktop at Work Part5 + url: https://blog.benoitj.ca/2020-06-15-how-i-use-linux-desktop-at-work-part5-dotfiles/ +- date: '2020-07-03' + version: 1.8.3 + title: Feeling at home in a LXD container + url: https://ubuntu.com/blog/feeling-at-home-in-a-lxd-container +- date: '2020-08-03' + version: 1.8.3 + title: Automating a Linux in Windows Dev Setup + url: https://matt.aimonetti.net/posts/2020-08-automating-a-linux-in-windows-dev-setup/ +- date: '2020-08-09' + version: 1.8.3 + title: Automating and testing dotfiles + url: https://seds.nl/posts/automating-and-testing-dotfiles/ +- date: '2020-08-13' + version: 1.8.3 + title: Using BitWarden and Chezmoi to manage SSH keys + url: https://www.jx0.uk/chezmoi/bitwarden/unix/ssh/2020/08/13/bitwarden-chezmoi-ssh-key.html +- date: '2020-10-03' + version: 1.8.6 + title: Chezmoi Merging + url: https://benoit.srht.site/2020-10-03-chezmoi-merging/ +- date: '2020-10-05' + version: 1.8.6 + title: Dotfiles with Chezmoi + url: https://blog.lazkani.io/posts/backup/dotfiles-with-chezmoi/ +- date: '2020-11-05' + version: 1.8.8 + title: Using chezmoi to manage dotfiles + url: https://pashinskikh.com/posts/chezmoi/ +- date: '2020-11-06' + version: 1.8.8 + title: Chezmoi – Securely Manage dotfiles across multiple machines + url: https://computingforgeeks.com/chezmoi-manage-dotfiles-across-multiple-machines/ +- date: '2021-01-12' + version: 1.8.10 + title: Automating the Setup of a New Mac With All Your Apps, Preferences, and Development Tools + url: https://www.moncefbelyamani.com/automating-the-setup-of-a-new-mac-with-all-your-apps-preferences-and-development-tools/ +- date: '2021-01-29' + version: 1.8.10 + lang: CN + title: 用 Chezmoi 管理配置文件 + url: https://axionl.me/p/%E5%BD%92%E6%A1%A3-%E7%94%A8-chezmoi-%E7%AE%A1%E7%90%86%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6/ +- date: '2021-02-07' + version: 1.8.10 + lang: JP + title: chezmoi始めた + url: https://joe-noh.hatenablog.com/entry/2021/02/07/215733 +- date: '2021-02-17' + version: 1.8.11 + lang: JP + title: chezmoi で dotfiles を手軽に柔軟にセキュアに管理する + url: https://zenn.dev/ryo_kawamata/articles/introduce-chezmoi +- date: '2021-04-01' + version: 2.0.7 + title: ChezMoi + url: https://johnmathews.is/chezmoi.html +- date: '2021-04-08' + version: 2.0.9 + lang: FR + title: Bienvenue chez moi + url: https://blogduyax.madyanne.fr/2021/bienvenue-chez-moi/ +- date: '2021-05-10' + version: 2.0.11 + title: Development Environment (2021) + url: https://ideas.offby1.net/posts/development-environment-2021.html +- date: '2021-05-12' + version: 2.0.12 + title: 'My Dotfiles Story: A Journey to Chezmoi' + url: https://www.mikekasberg.com/blog/2021/05/12/my-dotfiles-story.html +- date: '2021-05-14' + version: 2.0.12 + title: A brief history of my dotfile management + url: https://jonathanbartlett.co.uk/2021/05/14/a-brief-history-of-my-dotfiles.html +- date: '2021-07-15' + version: 2.1.2 + lang: CN + title: 使用Chezmoi管理配置文件 + url: https://marvinsblog.net/post/2021-07-15-chezmoi-intro/ +- date: '2021-07-23' + version: 2.1.2 + title: PBS 121 of X — Managing Dot Files and an Introduction to Chezmoi + url: https://pbs.bartificer.net/pbs121 +- date: '2021-08-04' + version: 2.1.2 + lang: PT + title: Como instalar o Chezmoi, um gerenciador de dotfiles, no Ubuntu, Linux Mint, Fedora, Debian + url: https://sempreupdate.com.br/como-instalar-o-chezmoi-um-gerenciador-de-dotfiles-no-ubuntu-linux-mint-fedora-debian/ +- date: '2021-08-08' + version: 2.1.2 + title: PBS 122 of X — Managing Dot Files with Chezmoi + url: https://pbs.bartificer.net/pbs122 +- date: '2021-08-22' + version: 2.1.2 + title: PBS 123 of X — Backing up and Syncing Dot Files with Chezmoi + url: https://pbs.bartificer.net/pbs123 +- date: '2021-09-04' + version: 2.1.2 + title: PBS 124 of X — Chezmoi Templates + url: https://pbs.bartificer.net/pbs124 +- date: '2021-09-04' + version: 2.2.0 + title: Configuration Management + url: https://cj.rs/blog/my-setup/chezmoi/ +- date: '2021-09-06' + version: 2.2.0 + title: chezmoi dotfile management + url: https://www.jacobbolda.com/chezmoi-dotfile-management +- date: '2021-09-14' + version: 2.2.0 + title: Managing preference plists under Chezmoi + url: https://zacwe.st/2021/09/14/managing-preference-plists-under-chezmoi/ +- date: '2021-09-18' + version: 2.1.2 + title: PBS 125 of X — Chezmoi on Multiple Computers + url: https://pbs.bartificer.net/pbs125 +- date: '2021-10-25' + version: 2.7.3 + title: Share credentials across machines using chezmoi and bitwarden + url: https://medium.com/@josemrivera/share-credentials-across-machines-using-chezmoi-and-bitwarden-4069dcb6e367 +- date: '2021-10-26' + version: 2.7.3 + lang: RU + title: Синхронизация системных настроек + url: https://habr.com/en/post/585578/ +- date: '2021-11-26' + version: 2.8.0 + title: Weekly Journal 47 - chezmoi, neovim + url: https://scottbanwart.com/blog/2021/11/weekly-journal-47-chezmoi-neovim/ +- date: '2021-12-01' + version: 2.9.1 + title: Chezmoi 2 + url: https://johnmathews.is/chezmoi-2.html +- date: '2021-12-04' + version: 2.9.2 + title: Advanced features of Chezmoi + url: https://zerokspot.com/weblog/2021/12/04/advanced-chezmoi/ +- date: '2021-12-13' + version: 2.9.3 + title: Managing Dotfiles With Chezmoi + url: https://budimanjojo.com/2021/12/13/managing-dotfiles-with-chezmoi/ +- date: '2021-12-20' + version: 2.9.3 + title: How chezmoi Implements Cross-Platform CI + url: https://gopheradvent.com/calendar/2021/how-chezmoi-implements-cross-platform-ci/ +- date: '2021-12-23' + version: 2.9.3 + title: Use Chezmoi to guarantee idempotency of terminal + url: https://www.wazaterm.com/blog/advent-calendar-2021/use-chezmoi-to-guarantee-idempotency-of-terminal +- date: '2022-01-12' + version: 2.9.5 + lang: IT + title: Come funzionano i miei Mac + url: https://correntedebole.com/come-funzionano-i-miei-mac/ +- date: '2022-01-26' + version: 2.10.1 + lang: JP + title: chezmoi で dotfiles を管理する + url: https://blog.zoncoen.net/2022/01/26/chezmoi/ +- date: '2022-02-01' + version: 2.10.1 + lang: JP + title: chezmoi で dotfiles を手軽に柔軟にセキュアに管理する + url: https://zenn.dev/ryo_kawamata/articles/introduce-chezmoi +- date: '2022-02-02' + version: 2.11.0 + lang: FR + title: Controler ses dotfiles en environnement éphémère + url: https://blog.wescale.fr/2022/02/02/controler-ses-dotfiles-en-environnement-ephemere-2/ +- date: '2022-02-12' + version: 2.11.2 + title: How To Manage Dotfiles With Chezmoi + url: https://jerrynsh.com/how-to-manage-dotfiles-with-chezmoi/ +- date: '2022-02-17' + version: 2.12.0 + lang: ES + title: Qué son y cómo gestionar archivos dotfiles con chezmoi + url: https://picodotdev.github.io/blog-bitix/2022/02/que-son-y-como-gestionar-archivos-dotfiles-con-chezmoi/ +- date: '2022-02-22' + version: 2.12.1 + lang: JP + title: chezmoi を使って VSCode devcontainer 対応 dotfiles を作る + url: https://www.mizdra.net/entry/2022/02/22/022109 +- date: '2022-03-03' + version: 2.13.0 + title: 'Local Environment-as-Code: Is It Possible Yet?' + url: https://thenewstack.io/local-environment-as-code-is-it-possible-yet/ +- date: '2022-03-13' + version: 2.14.0 + title: 'Tools I love: Chezmoi' + url: https://messmore.org/post/chezmoi/ +- date: '2022-04-25' + version: 2.15.1 + title: Easily moving Linux installs + url: https://christitus.com/chezmoi/ +- date: '2022-05-16' + version: 2.16.0 + title: Chezmoi for DotFiles + url: https://www.spatacoli.com/blog/2022/05/chezmoi-for-dotfiles/ +- date: '2022-06-02' + version: 2.17.1 + title: 'Local Env as Code: Is it possible yet' + url: https://www.cncf.io/blog/2022/06/02/local-env-as-code-is-it-possible-yet/ +- date: '2022-06-11' + version: 2.17.1 + lang: JP + title: chezmoi で Linux と macOS 両方で使える dotfiles を作る + url: https://www.docswell.com/s/iosiftakakura/K2EXM5-2022-06-11-chezmoi +- date: '2022-08-05' + version: 2.20.0 + lang: CN + title: 使用chezmoi管理dotfiles + url: https://zhaohongxuan.github.io/2022/08/05/use-chezmoi-manage-dotfiles/ +- date: '2022-08-11' + version: 2.20.0 + title: Chezmoi - a very cool tool to manage your dotfiles + url: https://wyssmann.com/blog/2022/08/chezmoi-a-very-cool-tool-to-manage-your-dotfiles/ +- date: '2022-09-13' + version: 2.22.1 + lang: IT + title: 'Come installare Chezmoi: gestisci in modo sicuro i dotfile su più macchine' + url: https://grayguide.net/it/come-installare-chezmoi-gestisci-in-modo-sicuro-i-dotfile-su-piu-macchine +- date: '2022-09-28' + version: 2.24.0 + title: Shit Hot Dotfiles + url: https://kolv.in/posts/dotfile-managment +- date: '2023-01-05' + version: 2.29.1 + lang: JP + title: 既存の dotfiles を chezmoi で管理する + url: https://zenn.dev/johnmanjiro13/articles/d14825f4ef3184 +- date: '2023-01-12' + version: 2.29.1 + lang: JP + title: Chezmoiでかんたんクロスプラットフォームdotfiles管理のススメ + url: https://deflis.hatenablog.com/entry/hatena-advent-calendar-2022-chezmoi-dotfiles +- date: '2023-01-13' + version: 2.29.1 + title: Making the most out of distrobox and toolbx + url: https://www.ypsidanger.com/making-the-most-out-of-distrobox-and-toolbx/ +- date: '2023-01-22' + version: 2.29.3 + lang: JP + title: dotfile manager の chezmoi に移行してみる + url: https://blog.yamano.dev/posts/2023/01/chezmoi-setup/ +- date: '2023-01-22' + version: 2.29.3 + title: Managing dotfiles + url: https://dnitza.com/post/managing-dotfiles +- date: '2023-02-26' + version: 2.31.0 + title: Managing dotfiles with chezmoi + url: https://moesgaard.dev/posts/2023-02-26-managing-dotfiles-with-chezmoi/ +- date: '2023-03-21' + version: 2.32.0 + lang: JP + title: AWS CLI のプロファイルを chezmoi とBitwarden で管理する + url: https://zenn.dev/nh8939/articles/8a6a4f5eb967a9 +- date: '2023-03-17' + version: 2.32.0 + title: Automating the Setup of a New Mac With All Your Apps, Preferences, and Development Tools + url: https://www.moncefbelyamani.com/automating-the-setup-of-a-new-mac-with-all-your-apps-preferences-and-development-tools/ +- date: '2023-03-25' + version: 2.33.0 + lang: KR + title: chezmoi, 세상 편리하게 dotfile 관리하기 + url: https://songkg7.github.io/posts/chezmoi-awesome-dotfile-manager/ +- date: '2023-04-08' + version: 2.33.1 + lang: KR + title: chezmoi, 본격적으로 활용하기 + url: https://songkg7.github.io/posts/chezmoi-basic-settings/ +- date: '2023-04-15' + version: 2.33.1 + lang: JP + title: dotfiles の管理に chezmoi を導入して fswatch で自動 apply できるようにしたg + url: https://blog.mono0x.net/2023/04/15/chezmoi/ +- date: '2023-04-26' + version: 2.33.1 + title: Managing my /home directory + url: https://aspatel.com/20230426180939/ +- date: '2023-04-29' + version: 2.33.1 + lang: JP + title: chezmoi のテンプレート機能を使ってシェルの起動を高速化する + url: https://blog.mono0x.net/2023/04/29/optimize-rcfiles-using-chezmoi/ +- date: '2023-05-16' + version: 2.33.6 + lang: JP + title: chezmoi + url: https://www.ebiyuu.com/dotfiles/chezmoi/ +- date: '2023-06-14' + version: 2.34.1 + lang: RU + title: chezmoi + url: https://principal-engineering.ru/posts/chezmoi/ +- date: '2023-08-05' + version: 2.36.1 + title: Dotfiles with chezmoi + url: https://www.steveyackey.com/post/dotfiles-with-chezmoi/ +- date: '2023-09-07' + version: 2.39.1 + lang: JP + title: 【chezmoi】dotfileのセキュアな値をdashlaneから参照できるようにする + url: https://zenn.dev/hulk510/articles/chezmoi-dashlane +- date: '2023-09-13' + version: 2.39.1 + lang: JP + title: 複数OSに対応しているchezmoiを使ってdotfilesを効率的に管理する + url: https://www.asobou.co.jp/blog/web/chezmoi +- date: '2023-10-29' + version: 2.40.4 + lang: CN + title: 用 chezmoi 管理 dotfiles + url: https://thewang.net/blog/manage-dotfiles-with-chezmoi +- date: '2023-12-10' + version: 2.42.2 + lang: JP + title: chezmoi で dotfiles を管理するときに便利な機能についてまとめる + url: https://zenn.dev/ganariya/articles/useful-features-of-chezmoi +- date: '2023-12-16' + version: 2.42.3 + lang: JP + title: chezmoi を使った dotfiles の管理方法 + url: https://zenn.dev/yukionodera/articles/how-to-manage-dotfiles +- date: '2023-12-17' + version: 2.42.3 + lang: JP + title: chezmoi を サブマシンにも導入する + url: https://zenn.dev/yukionodera/articles/setup-second-machine +- date: '2023-12-18' + version: 2.42.3 + lang: JP + title: chezmoi 管理のdotfiles でマシン毎に設定を変えたい + url: https://zenn.dev/yukionodera/articles/chezmoi-use-template +- date: '2023-12-23' + version: 2.42.3 + lang: DE + title: Lokale Kofigurationsdateien sicher mit chezmoi und Git synchronisieren + url: https://www.heise.de/ratgeber/Lokale-Kofigurationsdateien-sicher-mit-chezmoi-und-Git-synchronisieren-9580741.html +- date: '2024-01-07' + version: 2.43.0 + title: Chezmore Chezmoi + url: https://www.grumpymetalguy.com/tech_stack/chezmore/ +- date: '2024-02-25' + version: 2.47.0 + title: Atuin and chezmoi are the dog's bollocks + url: https://henry.catalinismith.com/2024/02/25/atuin-and-chezmoi-are-the-dogs-bollocks +- date: '2024-03-02' + version: 2.47.0 + title: The Ultimate Dotfiles Management Tool + url: https://medium.com/@bartelloni.guglielmo_39715/the-ultimate-dotfiles-management-tool-456177493974 +- date: '2024-03-11' + version: 2.47.1 + lang: CN + title: 'Chezmoi:優雅管理Linux的dotfile,使用Git儲存庫備份,類似GNU Stow' + url: https://ivonblog.com/posts/chezmoi-manage-dotfiles/ +- date: '2024-03-23' + version: 2.47.2 + lang: CN + title: '使用 chezmoi & vscode, 管理你的 dotfiles' + url: https://shansan.top/2024/03/23/using-chezmoi-to-manage-dotfiles/ +- date: '2024-03-23' + version: 2.47.2 + title: A tour around chezmoi + url: https://www.rousette.org.uk/archives/a-tour-around-chezmoi/ +- date: '2024-03-25' + version: 2.47.2 + title: Whatever happened to dotfiles? + url: https://ryan0x44.substack.com/p/whatever-happened-to-dotfiles +- date: '2024-04-24' + version: 2.47.4 + title: 'Chezmoi: Manage Your Dotfiles Across Multiple Linux Systems' + url: https://linuxtldr.com/installing-chezmoi/ +- date: '2024-04-27' + version: 2.48.0 + lang: CN + title: '使用 chezmoi & vscode, 管理你的 dotfiles' + url: https://juejin.cn/post/7362028633425608754 +- date: '2024-05-01' + version: 2.48.0 + lang: TH + title: 'จัดการ dotfiles ด้วย chezmoi' + url: https://www.anuwong.com/blog/manage-dotfiles-with-chezmoi/ +- date: '2024-06-26' + version: 2.49.1 + title: 'Automate Your Dotfiles with Chezmoi' + url: https://learn.typecraft.dev/tutorial/our-place-chezmoi/ diff --git a/assets/chezmoi.io/docs/links/dotfile-repos.md b/assets/chezmoi.io/docs/links/dotfile-repos.md new file mode 100644 index 00000000000..e4b17ba5ba8 --- /dev/null +++ b/assets/chezmoi.io/docs/links/dotfile-repos.md @@ -0,0 +1,7 @@ +# Dotfile repos + +* [GitHub](https://github.com/topics/chezmoi?o=desc&s=updated) + +* [GitLab](https://gitlab.com/explore/projects/topics/chezmoi) + +* [Codeberg](https://codeberg.org/explore/repos?sort=recentupdate&q=chezmoi&tab=) diff --git a/assets/chezmoi.io/docs/links/podcasts.md.tmpl b/assets/chezmoi.io/docs/links/podcasts.md.tmpl new file mode 100644 index 00000000000..24682314018 --- /dev/null +++ b/assets/chezmoi.io/docs/links/podcasts.md.tmpl @@ -0,0 +1,11 @@ +# Podcasts + +!!! tip + + Recommended podcast: [Managing Dot Files and an Introduction to Chezmoi](https://www.podfeet.com/blog/2021/07/ccatp-693/) + +| Date | Version | Language | Title | +| ---- | ------- | -------- | ----- | +{{- range mustReverse .podcasts }} +| {{ .date }} | {{ .version }} | {{ .lang | default "EN" }} | [{{ .title }}]({{ .url }}) | +{{- end }} diff --git a/assets/chezmoi.io/docs/links/podcasts.md.yaml b/assets/chezmoi.io/docs/links/podcasts.md.yaml new file mode 100644 index 00000000000..2334ad8a31b --- /dev/null +++ b/assets/chezmoi.io/docs/links/podcasts.md.yaml @@ -0,0 +1,43 @@ +podcasts: +- date: '2019-11-20' + version: 1.7.2 + title: 'FLOSS weekly episode 556: chezmoi' + url: https://twit.tv/shows/floss-weekly/episodes/556 +- date: '2021-07-23' + version: 2.1.2 + title: 'CCATP #693 – Bart Busschots on PBS 121 of X — Managing Dot Files and an Introduction to Chezmoi' + url: https://www.podfeet.com/blog/2021/07/ccatp-693/ +- date: '2021-08-08' + version: 2.1.2 + title: 'CCATP #695 – Bart Busschots on PBS 122 – Managing Dot Files with Chezmoi' + url: https://www.podfeet.com/blog/2021/08/ccatp-695/ +- date: '2021-08-22' + version: 2.1.2 + title: 'CCATP #696 – Bart Busschots on PBS 123 of X — Backing up and Syncing Dot Files with Chezmoi' + url: https://www.podfeet.com/blog/2021/08/ccatp-696/ +- date: '2021-09-04' + version: 2.1.2 + title: 'CCATP #698 – Bart Busschots on PBS 124 of X – Chezmoi Templates' + url: https://www.podfeet.com/blog/2021/09/ccatp-698/ +- date: '2021-09-18' + version: 2.1.2 + title: 'CCATP #699 – Bart Busschots on PBS 125 of X – Chezmoi on Multiple Computers' + url: https://www.podfeet.com/blog/2021/09/ccatp-699/ +- date: '2022-03-11' + version: 2.14.0 + title: 'The Real Python Podcast: Episode 101: Tools for Setting Up Python on a New Machine' + url: https://realpython.com/podcasts/rpp/101/#t=3368 +- date: '2022-05-27' + version: 2.17.0 + title: Fédérer une communauté technique autour d'un projet Open Source + url: https://www.podcastics.com/podcast/episode/federer-une-communaute-technique-autour-dun-projet-open-source-131694/ +- date: '2023-01-30' + version: 2.29.3 + lang: ES + title: 459 - Soy un zoquete, otra vez hice un rm -rf + url: https://atareao.es/podcast/soy-un-zoquete-otra-vez-hice-un-rm-rf/ +- date: '2023-05-22' + version: 2.33.6 + lang: ES + title: 491 - Tres herramientas que han revolucionado mi terminal Linux + url: https://atareao.es/podcast/tres-herramientas-que-han-revolucionado-mi-terminal-linux/ diff --git a/assets/chezmoi.io/docs/links/related-software.md b/assets/chezmoi.io/docs/links/related-software.md new file mode 100644 index 00000000000..4e4e496ca96 --- /dev/null +++ b/assets/chezmoi.io/docs/links/related-software.md @@ -0,0 +1,53 @@ +# Related software + +## Editor integration + +### [`github.com/alker0/chezmoi.vim`](https://github.com/alker0/chezmoi.vim) + +Intelligent VIM syntax highlighting when editing files in your source directory. +Works with both `chezmoi edit` and editing files directly. + +### [`github.com/tuh8888/chezmoi.el`](https://github.com/tuh8888/chezmoi.el) + +Convenience functions for interacting with chezmoi in Emacs. + +### [`github.com/Lilja/vim-chezmoi`](https://github.com/Lilja/vim-chezmoi) + +A plugin for VIM to apply the dotfile you are editing on `:w`. + +### [`github.com/xvzc/chezmoi.nvim`](https://github.com/xvzc/chezmoi.nvim) + +Edit your chezmoi-managed files and automatically apply. + +### [`https://github.com/GianniBYoung/chezmoi-telescope.nvim`](https://github.com/GianniBYoung/chezmoi-telescope.nvim) + +Custom Telescope Picker for Chez Moi Managed Dot files. + +## Other + +### [`atuin.sh`](https://atuin.sh/) + +Sync, search and backup shell history . + +### [`github.com/hussainweb/ansible-role-chezmoi`](https://github.com/hussainweb/ansible-role-chezmoi) + +Installs chezmoi on Ubuntu and Debian servers. + +### [`github.com/joke/asdf-chezmoi`](https://github.com/joke/asdf-chezmoi) + +chezmoi plugin for asdf version manager. + +### [`github.com/tcaxle/drapeau`](https://github.com/tcaxle/drapeau) + +An add-on to synchronize your colorschemes across systems and allow easy +colorscheme switching using chezmoi templates. + +### [`github.com/VorpalBlade/chezmoi_modify_manager`](https://github.com/VorpalBlade/chezmoi_modify_manager) + +An add-on to deal with config files that contain a mix of settings and +transient state, such as with GUI program settings files also containing +recently used files and window positions. + +### [`install.doctor`](https://install.doctor) + +Desktop provisioning system. diff --git a/assets/chezmoi.io/docs/links/social-media.md b/assets/chezmoi.io/docs/links/social-media.md new file mode 100644 index 00000000000..0d3ef1bdbaf --- /dev/null +++ b/assets/chezmoi.io/docs/links/social-media.md @@ -0,0 +1,11 @@ +# Social media + +| Platform | Search term | +| ----------- | -------------------------------------------------------------------------------------------------------------------------- | +| Hacker News | [`chezmoi`](https://hn.algolia.com/?dateRange=all&page=0&prefix=false&query=chezmoi&sort=byDate&type=comment) | +| LinkedIn | [`chezmoi dotfiles`](https://www.linkedin.com/search/results/all/?keywords=chezmoi%20dotfiles&origin=GLOBAL_SEARCH_HEADER) | +| Reddit | [`chezmoi`](https://www.reddit.com/search/?q=chezmoi&sort=new) | +| Twitter | [`chezmoi dotfiles`](https://twitter.com/search?q=chezmoi%20dotfiles&f=live) | +| Twitter | [`chezmoi.io`](https://twitter.com/search?q=chezmoi.io&f=live) | +| YouTube | [`chezmoi dotfiles`](https://www.youtube.com/results?search_query=chezmoi+dotfiles) | + diff --git a/assets/chezmoi.io/docs/links/videos.md.tmpl b/assets/chezmoi.io/docs/links/videos.md.tmpl new file mode 100644 index 00000000000..1e9319e8b80 --- /dev/null +++ b/assets/chezmoi.io/docs/links/videos.md.tmpl @@ -0,0 +1,11 @@ +# Videos + +!!! tip + + Recommended video: [chezmoi: manage your dotfiles across multiple, diverse machines, securely](https://fosdem.org/2021/schedule/event/chezmoi/) + +| Date | Version | Language | Title | +| ---- | ------- | -------- | ----- | +{{- range mustReverse .videos }} +| {{ .date }} | {{ .version }} | {{ .lang | default "EN" }} | [{{ .title }}]({{ .url }}) | +{{- end }} diff --git a/assets/chezmoi.io/docs/links/videos.md.yaml b/assets/chezmoi.io/docs/links/videos.md.yaml new file mode 100644 index 00000000000..6d2e6294c26 --- /dev/null +++ b/assets/chezmoi.io/docs/links/videos.md.yaml @@ -0,0 +1,55 @@ +videos: +- date: '2019-11-20' + version: 1.7.2 + title: 'FLOSS weekly episode 556: chezmoi' + url: https://twit.tv/shows/floss-weekly/episodes/556 +- date: '2020-03-12' + version: 1.7.16 + title: Managing Dotfiles with ChezMoi + url: https://www.youtube.com/watch?v=HXx6ugA98Qo +- date: '2020-07-06' + version: 1.8.3 + title: 'Conf42: chezmoi: Manage your dotfiles across multiple machines, securely' + url: https://www.youtube.com/watch?v=JrCMCdvoMAw +- date: '2021-02-06' + version: 1.8.10 + title: 'chezmoi: manage your dotfiles across multiple, diverse machines, securely' + url: https://fosdem.org/2021/schedule/event/chezmoi/ +- date: '2021-09-06' + version: 2.2.0 + title: 'chezmoi: Organize your dotfiles across multiple computers' + url: https://www.youtube.com/watch?v=L_Y3s0PS_Cg +- date: '2021-11-27' + version: 2.8.0 + lang: TH + title: 'Command ไร 2021-11-27 : ย้าย dotfiles ไป chezmoi' + url: https://www.youtube.com/watch?v=8ybNfCfnF2Y +- date: '2021-12-08' + version: 2.9.3 + title: How Go makes chezmoi possible + url: https://www.youtube.com/watch?v=5XiewS8ZbH8&t=1044s +- date: '2022-04-27' + version: 2.15.1 + title: Easily moving Linux installs + url: https://www.youtube.com/watch?v=x6063EuxfEA +- date: '2022-09-13' + version: 2.22.1 + title: Using chezmoi to automate dotfiles / config files (+ my bashrc) + url: https://www.youtube.com/watch?v=id5UKYuX4-A +- date: '2022-12-15' + version: 2.27.3 + lang: ES + title: Archivos de configuración fácil con chezmoi + url: https://www.youtube.com/watch?v=Xsh2DGSe6Lg +- date: '2023-12-03' + version: 2.42.2 + title: The ultimate dotfiles setup + url: https://www.youtube.com/watch?v=-RkANM9FfTM +- date: '2024-01-14' + version: 2.24.0 + title: 'managing dotfiles: a guided tour through my own setup' + url: https://www.youtube.com/watch?v=fQ3txCIxiiU +- date: '2024-02-17' + version: 2.47.0 + title: '12 GREAT command line programs YOU recommended!' + url: https://www.youtube.com/watch?v=nCS4BtJ34-o&t=324s diff --git a/assets/chezmoi.io/docs/logo.svg b/assets/chezmoi.io/docs/logo.svg new file mode 100644 index 00000000000..a0f403ca190 --- /dev/null +++ b/assets/chezmoi.io/docs/logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + diff --git a/assets/chezmoi.io/docs/migrating-from-another-dotfile-manager.md b/assets/chezmoi.io/docs/migrating-from-another-dotfile-manager.md new file mode 100644 index 00000000000..7aa8a41364f --- /dev/null +++ b/assets/chezmoi.io/docs/migrating-from-another-dotfile-manager.md @@ -0,0 +1,17 @@ +# Migrating from another dotfile manager + +## Migrate from a dotfile manager that uses symlinks + +Many dotfile managers replace dotfiles with symbolic links to files in a common +directory. If you `chezmoi add` such a symlink, chezmoi will add the symlink, +not the file. To assist with migrating from symlink-based systems, use the +`--follow` option to `chezmoi add`, for example: + +```console +$ chezmoi add --follow ~/.bashrc +``` + +This will tell `chezmoi add` that the target state of `~/.bashrc` is the target +of the `~/.bashrc` symlink, rather than the symlink itself. When you run +`chezmoi apply`, chezmoi will replace the `~/.bashrc` symlink with the file +contents. diff --git a/assets/chezmoi.io/docs/quick-start.md b/assets/chezmoi.io/docs/quick-start.md new file mode 100644 index 00000000000..6795b92dddf --- /dev/null +++ b/assets/chezmoi.io/docs/quick-start.md @@ -0,0 +1,233 @@ +# Quick start + +## Concepts + +Roughly speaking, chezmoi stores the desired state of your dotfiles in the +directory `~/.local/share/chezmoi`. When you run `chezmoi apply`, chezmoi +calculates the desired contents for each of your dotfiles and then makes the +minimum changes required to make your dotfiles match your desired state. +chezmoi's concepts are [described more accurately in the reference +manual](reference/concepts.md). + +## Start using chezmoi on your current machine + +Assuming that you have already [installed chezmoi](install.md), initialize +chezmoi with: + +```console +$ chezmoi init +``` + +This will create a new git local repository in `~/.local/share/chezmoi` where +chezmoi will store its source state. By default, chezmoi only modifies files in +the working copy. + +Manage your first file with chezmoi: + +```console +$ chezmoi add ~/.bashrc +``` + +This will copy `~/.bashrc` to `~/.local/share/chezmoi/dot_bashrc`. + +Edit the source state: + +```console +$ chezmoi edit ~/.bashrc +``` + +This will open `~/.local/share/chezmoi/dot_bashrc` in your `$EDITOR`. Make some +changes and save the file. + +!!! hint + + You don't have to use `chezmoi edit` to edit your dotfiles. See [this FAQ + entry](user-guide/frequently-asked-questions/usage.md#how-do-i-edit-my-dotfiles-with-chezmoi) + for more details. + +See what changes chezmoi would make: + +```console +$ chezmoi diff +``` + +Apply the changes: + +```console +$ chezmoi -v apply +``` + +All chezmoi commands accept the `-v` (verbose) flag to print out exactly what +changes they will make to the file system, and the `-n` (dry run) flag to not +make any actual changes. The combination `-n` `-v` is very useful if you want to +see exactly what changes would be made. + +Next, open a shell in the source directory, to commit your changes: + +```console +$ chezmoi cd +$ git add . +$ git commit -m "Initial commit" +``` + +[Create a new repository on GitHub](https://github.com/new) called `dotfiles` +and then push your repo: + +```console +$ git remote add origin git@github.com:$GITHUB_USERNAME/dotfiles.git +$ git branch -M main +$ git push -u origin main +``` + +!!! hint + + chezmoi can be configured to automatically add, commit, and push changes to + your repo. + +chezmoi can also be used with [GitLab](https://gitlab.com), or +[BitBucket](https://bitbucket.org), [Source Hut](https://sr.ht/), or any other +git hosting service. + +Finally, exit the shell in the source directory to return to where you were: + +```console +$ exit +``` + +These commands are summarized in this sequence diagram: + +```mermaid +sequenceDiagram + participant H as home directory + participant W as working copy + participant L as local repo + participant R as remote repo + H->>L: chezmoi init + H->>W: chezmoi add <file> + W->>W: chezmoi edit <file> + W-->>H: chezmoi diff + W->>H: chezmoi apply + H-->>W: chezmoi cd + W->>L: git add + W->>L: git commit + L->>R: git push + W-->>H: exit +``` + +## Using chezmoi across multiple machines + +On a second machine, initialize chezmoi with your dotfiles repo: + +```console +$ chezmoi init https://github.com/$GITHUB_USERNAME/dotfiles.git +``` + +!!! hint + + Private GitHub repos require other + [authentication methods](https://docs.github.com/en/get-started/getting-started-with-git/about-remote-repositories#cloning-with-https-urls): + + ```console + $ chezmoi init git@github.com:$GITHUB_USERNAME/dotfiles.git + ``` + +This will check out the repo and any submodules and optionally create a chezmoi +config file for you. + +Check what changes that chezmoi will make to your home directory by running: + +```console +$ chezmoi diff +``` + +If you are happy with the changes that chezmoi will make then run: + +```console +$ chezmoi apply -v +``` + +If you are not happy with the changes to a file then either edit it with: + +```console +$ chezmoi edit $FILE +``` + +Or, invoke a merge tool (by default `vimdiff`) to merge changes between the +current contents of the file, the file in your working copy, and the computed +contents of the file: + +```console +$ chezmoi merge $FILE +``` + +On any machine, you can pull and apply the latest changes from your repo with: + +```console +$ chezmoi update -v +``` + +These commands are summarized in this sequence diagram: + +```mermaid +sequenceDiagram + participant H as home directory + participant W as working copy + participant L as local repo + participant R as remote repo + R->>W: chezmoi init <repo> + W-->>H: chezmoi diff + W->>H: chezmoi apply + W->>W: chezmoi edit <file> + W->>W: chezmoi merge <file> + R->>H: chezmoi update +``` + +## Set up a new machine with a single command + +You can install your dotfiles on new machine with a single command: + +```console +$ chezmoi init --apply https://github.com/$GITHUB_USERNAME/dotfiles.git +``` + +If you use GitHub and your dotfiles repo is called `dotfiles` then this can be +shortened to: + +```console +$ chezmoi init --apply $GITHUB_USERNAME +``` + +!!! hint + + Private GitHub repos require other + [authentication methods](https://docs.github.com/en/get-started/getting-started-with-git/about-remote-repositories#cloning-with-https-urls): + + ```console + chezmoi init --apply git@github.com:$GITHUB_USERNAME/dotfiles.git + ``` + +This command is summarized in this sequence diagram: + +```mermaid +sequenceDiagram + participant H as home directory + participant W as working copy + participant L as local repo + participant R as remote repo + R->>H: chezmoi init --apply <repo> +``` + +## Next steps + +For a full list of commands run: + +```console +$ chezmoi help +``` + +chezmoi has much more functionality. Good starting points are reading [what +other people say about chezmoi](links/articles.md), adding more dotfiles, and +using templates to manage files that vary from machine to machine and retrieve +secrets from your password manager. Read the [user guide](user-guide/setup.md) +to explore and see [how people use chezmoi](links/dotfile-repos.md) for +inspiration. diff --git a/assets/chezmoi.io/docs/reference/application-order.md b/assets/chezmoi.io/docs/reference/application-order.md new file mode 100644 index 00000000000..555f0dff531 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/application-order.md @@ -0,0 +1,36 @@ +# Application order + +chezmoi is deterministic in its order of application. The order is: + +1. Read the source state. +2. Read the destination state. +3. Compute the target state. +4. Run `run_before_` scripts in alphabetical order. +5. Update entries in the target state (files, directories, externals, scripts, + symlinks, etc.) in alphabetical order of their target name. Directories + (including those created by externals) are updated before the files they + contain. +6. Run `run_after_` scripts in alphabetical order. + +Target names are considered after all attributes are stripped. + +!!! example + + Given `create_alpha` and `modify_dot_beta` in the source state, `.beta` + will be updated before `alpha` because `.beta` sorts before `alpha`. + +chezmoi assumes that the source or destination states are not modified while +chezmoi is being executed. This assumption permits significant performance +improvements, including allowing chezmoi to only read files from the source and +destination states if they are needed to compute the target state. + +chezmoi's behavior when the above assumptions are violated is undefined. For +example, using a `run_before_` script to update files in the source or +destination states violates the assumption that the source and destination +states do not change while chezmoi is running. + +!!! note + + External sources are updated during the update phase; it is inadvisable for + a `run_before_` script to depend on an external applied *during* the update + phase. `run_after_` scripts may freely depend on externals. diff --git a/assets/chezmoi.io/docs/reference/command-line-flags/common.md b/assets/chezmoi.io/docs/reference/command-line-flags/common.md new file mode 100644 index 00000000000..d081d40432f --- /dev/null +++ b/assets/chezmoi.io/docs/reference/command-line-flags/common.md @@ -0,0 +1,75 @@ +# Common command line flags + +The following flags apply to multiple commands where they are relevant. + +## `-f`, `--format` `json`|`yaml` + +Set the output format. + +## `-i`, `--include` *types* + +Include target state entries of type *types*. *types* is a comma-separated list +of types: + +| Type | Description | +| ----------- | --------------------------- | +| `all` | All entries (default) | +| `none` | No entries | +| `dirs` | Directories | +| `files` | Files | +| `remove` | Removes | +| `scripts` | Scripts | +| `symlinks` | Symbolic links | +| `always` | Scripts that are always run | +| `encrypted` | Encrypted entries | +| `externals` | External entries | +| `templates` | Templates | + +Types can be preceded with `no` to remove them. + +Types can be explicitly excluded with the `--exclude` flag. + +!!! example + + `--include=files` specifies all files. + +## `--init` + +Regenerate and reread the config file from the config file template before +computing the target state. + +## `--interactive` + +Prompt before applying each target. + +## `-o`, `--output` *filename* + +Write the output to *filename* instead of stdout. + +## `-r`, `--recursive` + +Recurse into subdirectories, `true` by default. + +## `--source-path` + +Interpret *targets* passed to the command as paths in the source directory +rather than the destination directory. + +## `--tree` + +Print paths as a tree instead of a list. + +## `--use-builtin-diff` + +Use chezmoi's builtin diff, even if the `diff.command` configuration variable +is set. + +## `-x`, `--exclude` *types* + +Exclude target state entries of type *types*. *types* is defined as in the +`--include` flag and defaults to `none`. + +!!! example + + `--exclude=scripts` will cause the command to not run scripts and + `--exclude=encrypted` will exclude encrypted files. diff --git a/assets/chezmoi.io/docs/reference/command-line-flags/developer.md b/assets/chezmoi.io/docs/reference/command-line-flags/developer.md new file mode 100644 index 00000000000..4bea7e87f24 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/command-line-flags/developer.md @@ -0,0 +1,11 @@ +# Developer command line flags + +The following flags are global but only relevant for developers and debugging. + +## `--cpu-profile` *filename* + +Write a [Go CPU profile](https://blog.golang.org/pprof) to *filename*. + +## `--debug` + +Log information helpful for debugging. diff --git a/assets/chezmoi.io/docs/reference/command-line-flags/global.md b/assets/chezmoi.io/docs/reference/command-line-flags/global.md new file mode 100644 index 00000000000..31c7a348a1d --- /dev/null +++ b/assets/chezmoi.io/docs/reference/command-line-flags/global.md @@ -0,0 +1,135 @@ +# Global command line flags + +## `--cache` *directory* + +> Configuration: `cacheDir` + +Use *directory* as the cache directory. + +## `--color` *value* + +> Configuration: `color` + +Colorize diffs, *value* can be `on`, `off`, `auto`, or any boolean-like value +recognized by `promptBool`. The default is `auto` which will colorize diffs only +if the environment variable `$NO_COLOR` is not set and stdout is a terminal. + +## `-c`, `--config` *filename* + +Read the [configuration](../configuration-file/index.md) from *filename*. + +## `--config-format` `json`|`jsonc`|`toml`|`yaml` + +Assume the configuration file is in the given format. This is only needed if +the config filename does not have an extension, for example when it is +`/dev/stdin`. + +## `-D`, `--destination` *directory* + +> Configuration: `destDir` + +Use *directory* as the destination directory. + +## `-n`, `--dry-run` + +Set dry run mode. In dry run mode, the destination directory is never modified. +This is most useful in combination with the `-v` (verbose) flag to print +changes that would be made without making them. + +## `--force` + +Make changes without prompting. + +## `-h`, `--help` + +Print help. + +## `-k`, `--keep-going` + +Keep going as far as possible after a encountering an error. + +## `--no-pager` + +Do not use the pager. + +## `--no-tty` + +Do not attempt to get a TTY for prompts. Instead, read them from stdin. + +## `--persistent-state` *filename* + +> Configuration: `persistentState` + +Read and write the persistent state from *filename*. By default, chezmoi stores +its persistent state in `chezmoistate.boltdb` in the same directory as its +configuration file. + +## `--progress` *value* + +Show progress when downloading externals. *value* can be `on`, `off`, or `auto`. +The default is `auto` which shows progress bars when stdout is a terminal. + +## `-R`, `--refresh-externals` [*value*] + +Control the refresh of the externals cache. *value* can be any of `always`, +`auto`, or `never` and defaults to `always` if no *value* is specified. If no +`--refresh-externals` flag is specified then chezmoi defaults to `auto`. + +`always` (or any truthy value as accepted by `parseBool`) causes chezmoi to +re-download externals. + +`auto` means only re-download externals that have not been downloaded within +their refresh periods. + +`never` (or any other falsey value accepted by `parseBool`) means only download +if no cached external is available. + +## `-S`, `--source` *directory* + +> Configuration: `sourceDir` + +Use *directory* as the source directory. + +## `--use-builtin-age` *value* + +> Configuration: `useBuiltinAge` + +Use chezmoi's builtin [age encryption](https://age-encryption.org) instead of an +external `age` command. *value* can be `on`, `off`, `auto`, or any boolean-like +value recognized by `promptBool`. The default is `auto` which will only use the +builtin age if `age.command` cannot be found in `$PATH`. + +The builtin `age` command does not support passphrases, symmetric encryption, +or the use of SSH keys. + +## `--use-builtin-git` *value* + +> Configuration: `useBuiltinGit` + +Use chezmoi's builtin git instead of `git.command` for the `init` and `update` +commands. *value* can be `on`, `off`, `auto`, or any boolean-like value +recognized by `promptBool`. The default is `auto` which will only use the +builtin git if `git.command` cannot be found in `$PATH`. + +!!! info + + chezmoi's builtin git has only supports the HTTP and HTTPS transports and + does not support `git-repo` externals. + +## `-v`, `--verbose` + +Set verbose mode. In verbose mode, chezmoi prints the changes that it is making +as approximate shell commands, and any differences in files between the target +state and the destination set are printed as unified diffs. + +## `--version` + +Print the version of chezmoi, the commit at which it was built, and the build +timestamp. + +## `-w`, `--working-tree` *directory* + +Use *directory* as the git working tree directory. By default, chezmoi searches +the source directory and then its ancestors for the first directory that +contains a `.git` directory. + diff --git a/assets/chezmoi.io/docs/reference/command-line-flags/index.md b/assets/chezmoi.io/docs/reference/command-line-flags/index.md new file mode 100644 index 00000000000..b51d8492b2e --- /dev/null +++ b/assets/chezmoi.io/docs/reference/command-line-flags/index.md @@ -0,0 +1,3 @@ +# Command line flags + +Command line flags override any values set in the configuration file. diff --git a/assets/chezmoi.io/docs/reference/commands/add.md b/assets/chezmoi.io/docs/reference/commands/add.md new file mode 100644 index 00000000000..be8b2b7c501 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/add.md @@ -0,0 +1,91 @@ +# `add` *target*... + +Add *target*s to the source state. If any target is already in the source +state, then its source state is replaced with its current state in the +destination directory. + +## `--autotemplate` + +Automatically generate a template by replacing strings that match variable +values from the `data` section of the config file with their respective config +names as a template string. Longer substitutions occur before shorter ones. +This implies the `--template` option. + +!!! warning + + `--autotemplate` uses a greedy algorithm which occasionally generates + templates with unwanted variable substitutions. Carefully review any + templates it generates. + +## `--encrypt` + +> Configuration: `add.encrypt` + +Encrypt files using the defined encryption method. + +## `-f`, `--force` + +Add *target*s, even if doing so would cause a source template to be +overwritten. + +## `--follow` + +If the last part of a target is a symlink, add the target of the symlink +instead of the symlink itself. + +## `--exact` + +Set the `exact` attribute on added directories. + +## `-i`, `--include` *types* + +Only add entries of type *types*. + +## `-p`, `--prompt` + +Interactively prompt before adding each file. + +## `-q`, `--quiet` + +Suppress warnings about adding ignored entries. + +## `-r`, `--recursive` + +Recursively add all files, directories, and symlinks. + +## `-s`, `--secrets` `ignore`|`warning`|`error` + +> Configuration: `add.secrets` + +Action to take when a secret is found when adding a file. The default is +`warning`. + +## `-T`, `--template` + +Set the `template` attribute on added files and symlinks. + +## `--template-symlinks` + +> Configuration: `add.templateSymlinks` + +When adding symlink to an absolute path in the source directory or destination +directory, create a symlink template with `.chezmoi.sourceDir` or +`.chezmoi.homeDir`. This is useful for creating portable absolute symlinks. + +!!! bug + + `chezmoi add` will fail if the entry being added is in a directory + implicitly created by an + [external](../special-files-and-directories/chezmoiexternal-format.md). + See [this GitHub issue](https://github.com/twpayne/chezmoi/issues/1574) for + details. + +!!! example + + ```console + $ chezmoi add ~/.bashrc + $ chezmoi add ~/.gitconfig --template + $ chezmoi add ~/.ssh/id_rsa --encrypt + $ chezmoi add ~/.vim --recursive + $ chezmoi add ~/.oh-my-zsh --exact --recursive + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/age.md b/assets/chezmoi.io/docs/reference/commands/age.md new file mode 100644 index 00000000000..9416a043484 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/age.md @@ -0,0 +1,18 @@ +# `age` + +Interact with age's passphrase-based encryption. + +!!! hint + + To get a full list of subcommands run: + + ```console + $ chezmoi age help + ``` + +!!! example + + ```console + $ chezmoi age encrypt --passphrase plaintext.txt > ciphertext.txt + $ chezmoi age decrypt --passphrase ciphertext.txt > decrypted-ciphertext.txt + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/apply.md b/assets/chezmoi.io/docs/reference/commands/apply.md new file mode 100644 index 00000000000..553320796d1 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/apply.md @@ -0,0 +1,23 @@ +# `apply` [*target*...] + +Ensure that *target*... are in the target state, updating them if necessary. If +no targets are specified, the state of all targets are ensured. If a target has +been modified since chezmoi last wrote it then the user will be prompted if +they want to overwrite the file. + +## `-i`, `--include` *types* + +Only add entries of type *types*. + +## `--source-path` + +Specify targets by source path, rather than target path. This is useful for +applying changes after editing. + +!!! example + + ```console + $ chezmoi apply + $ chezmoi apply --dry-run --verbose + $ chezmoi apply ~/.bashrc + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/archive.md b/assets/chezmoi.io/docs/reference/commands/archive.md new file mode 100644 index 00000000000..841a1fb7971 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/archive.md @@ -0,0 +1,26 @@ +# `archive` [*target*....] + +Generate an archive of the target state, or only the targets specified. This +can be piped into `tar` to inspect the target state. + +## `-f`, `--format` `tar`|`tar.gz`|`tgz`|`zip` + +Write the archive in *format*. If `--output` is set the format is guessed from +the extension, otherwise the default is `tar`. + +## `-i`, `--include` *types* + +Only include entries of type *types*. + +## `-z`, `--gzip` + +Compress the archive with gzip. This is automatically set if the format is +`tar.gz` or `tgz` and is ignored if the format is `zip`. + +!!! example + + ```console + $ chezmoi archive | tar tvf - + $ chezmoi archive --output=dotfiles.tar.gz + $ chezmoi archive --output=dotfiles.zip + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/cat-config.md b/assets/chezmoi.io/docs/reference/commands/cat-config.md new file mode 100644 index 00000000000..8d7e0774680 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/cat-config.md @@ -0,0 +1,9 @@ +# `cat-config` + +Print the configuration file. + +!!! example + + ```console + $ chezmoi cat-config + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/cat.md b/assets/chezmoi.io/docs/reference/commands/cat.md new file mode 100644 index 00000000000..d37cdd91983 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/cat.md @@ -0,0 +1,12 @@ +# `cat` *target*... + +Write the target contents of *target*s to stdout. *target*s must be files, +scripts, or symlinks. For files, the target file contents are written. For +scripts, the script's contents are written. For symlinks, the target is +written. + +!!! example + + ```console + $ chezmoi cat ~/.bashrc + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/cd.md b/assets/chezmoi.io/docs/reference/commands/cd.md new file mode 100644 index 00000000000..b9de894df2e --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/cd.md @@ -0,0 +1,29 @@ +# `cd` [*path*] + +Launch a shell in the working tree (typically the source directory). chezmoi +will launch the command set by the `cd.command` configuration variable with any +extra arguments specified by `cd.args`. If this is not set, chezmoi will +attempt to detect your shell and finally fall back to an OS-specific default. + +If the optional argument *path* is present, the shell will be launched in the +source directory corresponding to *path*. + +The shell will have various `CHEZMOI*` environment variables set, as for +scripts. + +!!! hint + + This does not change the current directory of the current shell. To do + that, instead use: + + ```console + $ cd $(chezmoi source-path) + ``` + +!!! example + + ```console + $ chezmoi cd + $ chezmoi cd ~ + $ chezmoi cd ~/.config + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/chattr.md b/assets/chezmoi.io/docs/reference/commands/chattr.md new file mode 100644 index 00000000000..3124522a17f --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/chattr.md @@ -0,0 +1,50 @@ +# `chattr` *modifier* *target*... + +Change the attributes and/or type of *target*s. *modifier* specifies what to +modify. + +Add attributes by specifying them or their abbreviations directly, optionally +prefixed with a plus sign (`+`). Remove attributes by prefixing them or their +attributes with the string `no` or a minus sign (`-`). The available attribute +modifiers and their abbreviations are: + +| Attribute modifier | Abbreviation | +| ------------------ | ------------ | +| `after` | `a` | +| `before` | `b` | +| `empty` | `e` | +| `encrypted` | *none* | +| `exact` | *none* | +| `executable` | `x` | +| `external` | *none* | +| `once` | `o` | +| `private` | `p` | +| `readonly` | `r` | +| `remove` | *none* | +| `template` | `t` | + +The type of a target can be changed using a type modifier: + +| Type modifier | +| ------------- | +| `create` | +| `modify` | +| `script` | +| `symlink` | + +The negative form of type modifiers, e.g. `nocreate`, changes the target to be +a regular file if it is of that type, otherwise the type is left unchanged. + +Multiple modifications may be specified by separating them with a comma (`,`). +If you use the `-`*modifier* form then you must put *modifier* after a `--` to +prevent chezmoi from interpreting `-`*modifier* as an option. + +!!! example + + ```console + $ chezmoi chattr template ~/.bashrc + $ chezmoi chattr noempty ~/.profile + $ chezmoi chattr private,template ~/.netrc + $ chezmoi chattr -- -x ~/.zshrc + $ chezmoi chattr +create,+private ~/.kube/config + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/commands.go b/assets/chezmoi.io/docs/reference/commands/commands.go new file mode 100644 index 00000000000..a805e55c876 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/commands.go @@ -0,0 +1,9 @@ +// Package commands contains chezmoi's documentation for commands. +package commands + +import "embed" + +// FS contains all docs. +// +//go:embed *.md +var FS embed.FS diff --git a/assets/chezmoi.io/docs/reference/commands/commands_test.go b/assets/chezmoi.io/docs/reference/commands/commands_test.go new file mode 100644 index 00000000000..09e6ee20335 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/commands_test.go @@ -0,0 +1,12 @@ +package commands + +import ( + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestFS(t *testing.T) { + _, err := FS.ReadFile("add.md") + assert.NoError(t, err) +} diff --git a/assets/chezmoi.io/docs/reference/commands/completion.md b/assets/chezmoi.io/docs/reference/commands/completion.md new file mode 100644 index 00000000000..8ff1657f6d2 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/completion.md @@ -0,0 +1,11 @@ +# `completion` *shell* + +Generate shell completion code for the specified shell (`bash`, `fish`, +`powershell`, or `zsh`). + +!!! example + + ```console + $ chezmoi completion bash + $ chezmoi completion fish --output=~/.config/fish/completions/chezmoi.fish + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/data.md b/assets/chezmoi.io/docs/reference/commands/data.md new file mode 100644 index 00000000000..d04ea63cca3 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/data.md @@ -0,0 +1,14 @@ +# `data` + +Write the computed template data to stdout. + +## `-f`, `--format` `json`|`yaml` + +Set the output format. + +!!! example + + ```console + $ chezmoi data + $ chezmoi data --format=yaml + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/decrypt.md b/assets/chezmoi.io/docs/reference/commands/decrypt.md new file mode 100644 index 00000000000..bfdd9bee2cb --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/decrypt.md @@ -0,0 +1,5 @@ +# `decrypt` [*file*...] + +Decrypt *file*s using chezmoi's configured encryption. If no files are given, +decrypt the standard input. The decrypted result is written to the standard +output or a file if the `--output` flag is set. diff --git a/assets/chezmoi.io/docs/reference/commands/destroy.md b/assets/chezmoi.io/docs/reference/commands/destroy.md new file mode 100644 index 00000000000..6b5927e0452 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/destroy.md @@ -0,0 +1,17 @@ +# `destroy` *target*... + +!!! warning + + The `destroy` command permanently removes files both from your home directory and chezmoi's source directory. + + Only run `chezmoi destroy` if you have a separate backup of your home directory and your source directory. + + If you want chezmoi to stop managing the file use [`forget`](forget.md) instead. + + If you want to remove all traces of chezmoi from your system use [`purge`](purge.md) instead. + +Remove *target* from the source state, the destination directory, and the state. + +## `-f`, `--force` + +Destroy without prompting. diff --git a/assets/chezmoi.io/docs/reference/commands/diff.md b/assets/chezmoi.io/docs/reference/commands/diff.md new file mode 100644 index 00000000000..63cf7322635 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/diff.md @@ -0,0 +1,36 @@ +# `diff` [*target*...] + +Print the difference between the target state and the destination state for +*target*s. If no targets are specified, print the differences for all targets. + +If a `diff.pager` command is set in the configuration file then the output will +be piped into it. + +If `diff.command` is set then it will be invoked to show individual file +differences with `diff.args` passed as arguments. Each element of `diff.args` +is interpreted as a template with the variables `.Destination` and `.Target` +available corresponding to the path of the file in the source and target state +respectively. The default value of `diff.args` is +`["{{ .Destination }}", "{{ .Target }}"]`. If `diff.args` does not contain any +template arguments then `{{ .Destination }}` and `{{ .Target }}` will be +appended automatically. + +## `--reverse` + +> Configuration: `diff.reverse` + +Reverse the direction of the diff, i.e. show the changes to the target required +to match the destination. + +## `--pager` *pager* + +> Configuration: `diff.pager` + +Pager to use for output. + +!!! example + + ```console + $ chezmoi diff + $ chezmoi diff ~/.bashrc + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/doctor.md b/assets/chezmoi.io/docs/reference/commands/doctor.md new file mode 100644 index 00000000000..6b29f04f0ba --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/doctor.md @@ -0,0 +1,13 @@ +# `doctor` + +Check for potential problems. + +## `--no-network` + +Do not use any network connections. + +!!! example + + ```console + $ chezmoi doctor + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/dump-config.md b/assets/chezmoi.io/docs/reference/commands/dump-config.md new file mode 100644 index 00000000000..89a3fbe09ce --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/dump-config.md @@ -0,0 +1,9 @@ +# `dump-config` + +Dump the configuration. + +!!! example + + ```console + $ chezmoi dump-config + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/dump.md b/assets/chezmoi.io/docs/reference/commands/dump.md new file mode 100644 index 00000000000..c62b65e6f70 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/dump.md @@ -0,0 +1,19 @@ +# `dump` [*target*...] + +Dump the target state of *target*s. If no targets are specified, then the +entire target state. + +## `-f`, `--format` `json`|`yaml` + +Set the output format. + +## `-i`, `--include` *types* + +Only include entries of type *types*. + +!!! example + + ```console + $ chezmoi dump ~/.bashrc + $ chezmoi dump --format=yaml + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/edit-config-template.md b/assets/chezmoi.io/docs/reference/commands/edit-config-template.md new file mode 100644 index 00000000000..be13821dde8 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/edit-config-template.md @@ -0,0 +1,10 @@ +# `edit-config-template` + +Edit the configuration file template. If no configuration file template exists, +then a new one is created with the contents of the current config file. + +!!! example + + ```console + $ chezmoi edit-config-template + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/edit-config.md b/assets/chezmoi.io/docs/reference/commands/edit-config.md new file mode 100644 index 00000000000..3346838ec93 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/edit-config.md @@ -0,0 +1,9 @@ +# `edit-config` + +Edit the configuration file. + +!!! example + + ```console + $ chezmoi edit-config + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/edit.md b/assets/chezmoi.io/docs/reference/commands/edit.md new file mode 100644 index 00000000000..2312424ada3 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/edit.md @@ -0,0 +1,49 @@ +# `edit` [*target*...] + +Edit the source state of *target*s, which must be files or symlinks. If no +targets are given then the working tree of the source directory is opened. + +Encrypted files are decrypted to a private temporary directory and the editor +is invoked with the decrypted file. When the editor exits the edited decrypted +file is re-encrypted and replaces the original file in the source state. + +If the operating system supports hard links, then the edit command invokes the +editor with filenames which match the target filename, unless the +`edit.hardlink` configuration variable is set to `false` the `--hardlink=false` +command line flag is set. + +## `-a`, `--apply` + +> Configuration: `edit.apply` + +Apply target immediately after editing. Ignored if there are no targets. + +## `--hardlink` *bool* + +> Configuration: `edit.hardlink` + +Invoke the editor with a hard link to the source file with a name matching the +target filename. This can help the editor determine the type of the file +correctly. This is the default. + +## `--watch` + +> Configuration: `edit.watch` + +Automatically apply changes when files are saved, with the following limitations: + +* Only available when `chezmoi edit` is invoked with arguments (i.e. + argument-free `chezmoi edit` is not supported). +* All edited files are applied when any file is saved. +* Only the edited files are watched, not any dependent files (e.g. + `.chezmoitemplates` and `include`d files in templates are not watched). +* Only works on operating systems supported by + [fsnotify](https://github.com/fsnotify/fsnotify). + +!!! example + + ```console + $ chezmoi edit ~/.bashrc + $ chezmoi edit ~/.bashrc --apply + $ chezmoi edit + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/encrypt.md b/assets/chezmoi.io/docs/reference/commands/encrypt.md new file mode 100644 index 00000000000..abbb865a902 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/encrypt.md @@ -0,0 +1,5 @@ +# `encrypt` [*file*...] + +Encrypt *file*s using chezmoi's configured encryption. If no files are given, +encrypt the standard input. The encrypted result is written to the standard +output or a file if the `--output` flag is set. diff --git a/assets/chezmoi.io/docs/reference/commands/execute-template.md b/assets/chezmoi.io/docs/reference/commands/execute-template.md new file mode 100644 index 00000000000..d663c040fe9 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/execute-template.md @@ -0,0 +1,65 @@ +# `execute-template` [*template*...] + +Execute *template*s. This is useful for [testing +templates](../../user-guide/templating.md#testing-templates) or for calling +chezmoi from other scripts. *templates* are interpreted as literal templates, +with no whitespace added to the output between arguments. If no templates are +specified, the template is read from stdin. + +## `--init`, `-i` + +Include simulated functions only available during `chezmoi init`. + +## `--left-delimiter` *delimiter* + +Set the left template delimiter. + +## `--promptBool` *pairs* + +Simulate the `promptBool` template function with a function that returns values +from *pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If +`promptBool` is called with a *prompt* that does not match any of *pairs*, then +it returns false. + +## `--promptChoice` *pairs* + +Simulate the `promptChoice` template function with a function that returns +values from *pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* +pairs. If `promptChoice` is called with a *prompt* that does not match any of +*pairs*, then it returns false. + +## `--promptInt` *pairs* + +Simulate the `promptInt` template function with a function that returns values +from *pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If +`promptInt` is called with a *prompt* that does not match any of *pairs*, then +it returns zero. + +## `--promptString`, `-p` *pairs* + +Simulate the `promptString` template function with a function that returns +values from *pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* +pairs. If `promptString` is called with a *prompt* that does not match any of +*pairs*, then it returns *prompt* unchanged. + +## `--right-delimiter` *delimiter* + +Set the right template delimiter. + +## `--stdinisatty` *bool* + +Simulate the `stdinIsATTY` function by returning *bool*. + +## `--with-stdin` + +If run with arguments, then set `.chezmoi.stdin` to the contents of the standard +input. + +!!! example + + ```console + $ chezmoi execute-template '{{ .chezmoi.sourceDir }}' + $ chezmoi execute-template '{{ .chezmoi.os }}' / '{{ .chezmoi.arch }}' + $ echo '{{ .chezmoi | toJson }}' | chezmoi execute-template + $ chezmoi execute-template --init --promptString email=me@home.org < ~/.local/share/chezmoi/.chezmoi.toml.tmpl + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/forget.md b/assets/chezmoi.io/docs/reference/commands/forget.md new file mode 100644 index 00000000000..64cbdf1ecff --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/forget.md @@ -0,0 +1,10 @@ +# `forget` *target*... + +Remove *target*s from the source state, i.e. stop managing them. *target*s must +have entries in the source state. They cannot be externals. + +!!! example + + ```console + $ chezmoi forget ~/.bashrc + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/generate.md b/assets/chezmoi.io/docs/reference/commands/generate.md new file mode 100644 index 00000000000..ed3012b44a9 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/generate.md @@ -0,0 +1,15 @@ +# `generate` *output* + +Generates *output* for use with chezmoi. The currently supported *output*s are: + +| Output | Description | +| -------------------- | --------------------------------------------------------------------- | +| `git-commit-message` | A git commit message, describing the changes to the source directory. | +| `install.sh` | An install script, suitable for use with Github Codespaces | + +!!! example + + ```console + $ chezmoi generate install.sh > install.sh + $ chezmoi git commit -m "$(chezmoi generate git-commit-message)" + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/git.md b/assets/chezmoi.io/docs/reference/commands/git.md new file mode 100644 index 00000000000..ad97438583f --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/git.md @@ -0,0 +1,16 @@ +# `git` [*arg*...] + +Run `git` *args* in the working tree (typically the source directory). + +!!! note + + Flags in *args* must occur after `--` to prevent chezmoi from interpreting + them. + +!!! example + + ```console + $ chezmoi git add . + $ chezmoi git add dot_gitconfig + $ chezmoi git -- commit -m "Add .gitconfig" + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/help.md b/assets/chezmoi.io/docs/reference/commands/help.md new file mode 100644 index 00000000000..23b5052d922 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/help.md @@ -0,0 +1,4 @@ +# `help` [*command*...] + +Print the help associated with *command*, or general help if no command is +given. diff --git a/assets/chezmoi.io/docs/reference/commands/ignored.md b/assets/chezmoi.io/docs/reference/commands/ignored.md new file mode 100644 index 00000000000..d967b033ed9 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/ignored.md @@ -0,0 +1,9 @@ +# `ignored` + +Print the list of entries ignored by chezmoi. + +!!! example + + ```console + $ chezmoi ignored + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/import.md b/assets/chezmoi.io/docs/reference/commands/import.md new file mode 100644 index 00000000000..4c4dd4ab640 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/import.md @@ -0,0 +1,33 @@ +# `import` *filename* + +Import the source state from an archive file in to a directory in the source +state. This is primarily used to make subdirectories of your home directory +exactly match the contents of a downloaded archive. You will generally always +want to set the `--destination`, `--exact`, and `--remove-destination` flags. + +The supported archive formats are `tar`, `tar.gz`, `tgz`, `tar.bz2`, `tbz2`, +`xz`, `.tar.zst`, and `zip`. + +## `--destination` *directory* + +Set the destination (in the source state) where the archive will be imported. + +## `--exact` + +Set the `exact` attribute on all imported directories. + +## `-r`, `--remove-destination` + +Remove destination (in the source state) before importing. + +## `--strip-components` *n* + +Strip *n* leading components from paths. + +!!! example + + ```console + $ curl -s -L -o ${TMPDIR}/oh-my-zsh-master.tar.gz https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz + $ mkdir -p $(chezmoi source-path)/dot_oh-my-zsh + $ chezmoi import --strip-components 1 --destination ~/.oh-my-zsh ${TMPDIR}/oh-my-zsh-master.tar.gz + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/init.md b/assets/chezmoi.io/docs/reference/commands/init.md new file mode 100644 index 00000000000..c73574b5473 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/init.md @@ -0,0 +1,131 @@ +# `init` [*repo*] + +Setup the source directory, generate the config file, and optionally update the +destination directory to match the target state. + +By default, if *repo* is given, chezmoi will guess the full git repo URL, using +HTTPS by default, or SSH if the `--ssh` option is specified, according to the +following patterns: + +| Pattern | HTTPS Repo | SSH repo | +| ------------------ | ------------------------------------------- | ---------------------------------- | +| `user` | `https://user@github.com/user/dotfiles.git` | `git@github.com:user/dotfiles.git` | +| `user/repo` | `https://user@github.com/user/repo.git` | `git@github.com:user/repo.git` | +| `site/user/repo` | `https://user@site/user/repo.git` | `git@site:user/repo.git` | +| `sr.ht/~user` | `https://user@git.sr.ht/~user/dotfiles` | `git@git.sr.ht:~user/dotfiles.git` | +| `sr.ht/~user/repo` | `https://user@git.sr.ht/~user/repo` | `git@git.sr.ht:~user/repo.git` | + +To disable git repo URL guessing, pass the `--guess-repo-url=false` option. + +First, if the source directory does not already contain a repository, then if +*repo* is given, it is checked out into the source directory; otherwise a new +repository is initialized in the source directory. + +Second, if a file called `.chezmoi.$FORMAT.tmpl` exists, where `$FORMAT` is one +of the supported file formats (e.g. `json`, `jsonc`, `toml`, or `yaml`) then a +new configuration file is created using that file as a template. + +Then, if the `--apply` flag is passed, `chezmoi apply` is run. + +Then, if the `--purge` flag is passed, chezmoi will remove its source, config, +and cache directories. + +Finally, if the `--purge-binary` is passed, chezmoi will attempt to remove its +own binary. + +## `--apply` + +Run `chezmoi apply` after checking out the repo and creating the config file. + +## `--branch` *branch* + +Check out *branch* instead of the default branch. + +## `--config-path` *path* + +Write the generated config file to *path* instead of the default location. + +## `--data` *bool* + +Include existing template data when creating the config file. This defaults to +`true`. Set this to `false` to simulate creating the config file with no +existing template data. + +## `--depth` *depth* + +Clone the repo with depth *depth*. + +## `--prompt` + +Force the `prompt*Once` template functions to prompt. + +## `--promptBool` *pairs* + +Populate the `promptBool` template function with values from *pairs*. *pairs* is +a comma-separated list of *prompt*`=`*value* pairs. If `promptBool` is called +with a *prompt* that does not match any of *pairs*, then it prompts the user for +a value. + +## `--promptChoice` *pairs* + +Populate the `promptChoice` template function with values from *pairs*. *pairs* +is a comma-separated list of *prompt*`=`*value* pairs. If `promptChoice` is +called with a *prompt* that does not match any of *pairs*, then it prompts the +user for a value. + +## `--promptDefaults` + +Make all `prompt*` template function calls with a default value return that +default value instead of prompting. + +## `--promptInt` *pairs* + +Populate the `promptInt` template function with values from *pairs*. *pairs* is +a comma-separated list of *prompt*`=`*value* pairs. If `prompInt` is called +with a *prompt* that does not match any of *pairs*, then it prompts the user for +a value. + +## `--promptString` *pairs* + +Populate the `promptString` template function with values from *pairs*. *pairs* is +a comma-separated list of *prompt*`=`*value* pairs. If `promptString` is called +with a *prompt* that does not match any of *pairs*, then it prompts the user for +a value. + +## `--guess-repo-url` *bool* + +Guess the repo URL from the *repo* argument. This defaults to `true`. + +## `--one-shot` + +`--one-shot` is the equivalent of `--apply`, `--depth=1`, `--force`, `--purge`, +and `--purge-binary`. It attempts to install your dotfiles with chezmoi and +then remove all traces of chezmoi from the system. This is useful for setting +up temporary environments (e.g. Docker containers). + +## `--purge` + +Remove the source and config directories after applying. + +## `--purge-binary` + +Attempt to remove the chezmoi binary after applying. + +## `--recurse-submodules` *bool* + +Recursively clone submodules. This defaults to `true`. + +## `--ssh` + +Guess an SSH repo URL instead of an HTTPS repo. + +!!! example + + ```console + $ chezmoi init user + $ chezmoi init user --apply + $ chezmoi init user --apply --purge + $ chezmoi init user/dots + $ chezmoi init codeberg.org/user + $ chezmoi init gitlab.com/user + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/license.md b/assets/chezmoi.io/docs/reference/commands/license.md new file mode 100644 index 00000000000..03fccf3126f --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/license.md @@ -0,0 +1,9 @@ +# `license` + +Print chezmoi's license. + +!!! example + + ```console + $ chezmoi license + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/list.md b/assets/chezmoi.io/docs/reference/commands/list.md new file mode 100644 index 00000000000..821a7b2024e --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/list.md @@ -0,0 +1,3 @@ +# `list` + +`list` is an alias for `managed`. diff --git a/assets/chezmoi.io/docs/reference/commands/manage.md b/assets/chezmoi.io/docs/reference/commands/manage.md new file mode 100644 index 00000000000..a464c0b3e1c --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/manage.md @@ -0,0 +1,3 @@ +# `manage` *target*... + +`manage` is an alias for `add` for symmetry with `unmanage`. diff --git a/assets/chezmoi.io/docs/reference/commands/managed.md b/assets/chezmoi.io/docs/reference/commands/managed.md new file mode 100644 index 00000000000..65463f01dc2 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/managed.md @@ -0,0 +1,22 @@ +# `managed` [*path*...] + +List all managed entries in the destination directory under all *path*s in +alphabetical order. When no *path*s are supplied, list all managed entries in +the destination directory in alphabetical order. + +## `-p`, `--path-style` `absolute`|`relative`|`source-absolute`|`source-relative` + +Print paths in the given style. Relative paths are relative to the destination +directory. The default is `relative`. + +!!! example + + ```console + $ chezmoi managed + $ chezmoi managed --include=files + $ chezmoi managed --include=files,symlinks + $ chezmoi managed -i dirs + $ chezmoi managed -i dirs,files + $ chezmoi managed -i files ~/.config + $ chezmoi managed --exclude=encrypted --path-style=source-relative + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/merge-all.md b/assets/chezmoi.io/docs/reference/commands/merge-all.md new file mode 100644 index 00000000000..7ddc6205ad8 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/merge-all.md @@ -0,0 +1,10 @@ +# `merge-all` + +Perform a three-way merge for file whose actual state does not match its target +state. The merge is performed with `chezmoi merge`. + +!!! example + + ```console + $ chezmoi merge-all + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/merge.md b/assets/chezmoi.io/docs/reference/commands/merge.md new file mode 100644 index 00000000000..50216cbe3de --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/merge.md @@ -0,0 +1,23 @@ +# `merge` *target*... + +Perform a three-way merge between the destination state, the target state, and +the source state for each *target*. The merge tool is defined by the +`merge.command` configuration variable, and defaults to `vimdiff`. If multiple +targets are specified the merge tool is invoked separately and sequentially for +each target. If the target state cannot be computed (for example if source is a +template containing errors or an encrypted file that cannot be decrypted) a +two-way merge is performed instead. + +The order of arguments to `merge.command` is set by `merge.args`. Each argument +is interpreted as a template with the variables `.Destination`, `.Source`, and +`.Target` available corresponding to the path of the file in the destination +state, the source state, and the target state respectively. The default value +of `merge.args` is `["{{ .Destination }}", "{{ .Source }}", "{{ .Target }}"]`. +If `merge.args` does not contain any template arguments then `{{ .Destination +}}`, `{{ .Source }}`, and `{{ .Target }}` will be appended automatically. + +!!! example + + ```console + $ chezmoi merge ~/.bashrc + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/purge.md b/assets/chezmoi.io/docs/reference/commands/purge.md new file mode 100644 index 00000000000..a1b7582314a --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/purge.md @@ -0,0 +1,15 @@ +# `purge` + +Remove chezmoi's configuration, state, and source directory, but leave the +target state intact. + +## `-f`, `--force` + +Remove without prompting. + +!!! example + + ```console + $ chezmoi purge + $ chezmoi purge --force + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/re-add.md b/assets/chezmoi.io/docs/reference/commands/re-add.md new file mode 100644 index 00000000000..5d35ac97b93 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/re-add.md @@ -0,0 +1,24 @@ +# `re-add` [*target*...] + +Re-add modified files in the target state, preserving any `encrypted_` +attributes. chezmoi will not overwrite templates, and all entries that are not +files are ignored. Directories are recursed into by default. + +If no *target*s are specified then all modified files are re-added. If one or +more *target*s are given then only those targets are re-added. + +## `-r`, `--recursive` + +Recursively add files in subdirectories. + +!!! hint + + If you want to re-add a single file unconditionally, use `chezmoi add --force` instead. + +!!! example + + ```console + $ chezmoi re-add + $ chezmoi re-add ~/.bashrc + $ chezmoi re-add --recursive=false ~/.config/git + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/remove.md b/assets/chezmoi.io/docs/reference/commands/remove.md new file mode 100644 index 00000000000..cd3c635abd5 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/remove.md @@ -0,0 +1,4 @@ +# `remove` + +The `remove` command has been removed. Use the [`forget` command](forget.md) or +the [`destroy` command](destroy.md) instead. diff --git a/assets/chezmoi.io/docs/reference/commands/rm.md b/assets/chezmoi.io/docs/reference/commands/rm.md new file mode 100644 index 00000000000..18d8019a23e --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/rm.md @@ -0,0 +1,4 @@ +# `rm` + +The `rm` command has been removed. Use the [`forget` command](forget.md) or the +[`destroy` command](destroy.md) instead. diff --git a/assets/chezmoi.io/docs/reference/commands/secret.md b/assets/chezmoi.io/docs/reference/commands/secret.md new file mode 100644 index 00000000000..9753287f6bc --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/secret.md @@ -0,0 +1,33 @@ +# `secret` + +Run a secret manager's CLI, passing any extra arguments to the secret manager's +CLI. This is primarily for verifying chezmoi's integration with a custom secret +manager. Normally you would use chezmoi's existing template functions to retrieve secrets. + +!!! note + + If you need to pass flags to the secret manager's CLI you must separate + them with `--` to prevent chezmoi from interpreting them. + +!!! hint + + To get a full list of subcommands run: + + ```console + $ chezmoi secret help + ``` + +!!! example + + ```console + $ chezmoi secret keyring set --service=service --user=user --value=password + $ chezmoi secret keyring get --service=service --user=user + $ chezmoi secret keyring delete --service=service --user=user + ``` + +!!! warning + + On FreeBSD, the `secret keyring` command is only available if chezmoi was + compiled with cgo enabled. The official release binaries of chezmoi are + **not** compiled with cgo enabled, and `secret keyring` command is not + available. diff --git a/assets/chezmoi.io/docs/reference/commands/source-path.md b/assets/chezmoi.io/docs/reference/commands/source-path.md new file mode 100644 index 00000000000..55e8c376d95 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/source-path.md @@ -0,0 +1,11 @@ +# `source-path` [*target*...] + +Print the path to each target's source state. If no targets are specified then +print the source directory. + +!!! example + + ```console + $ chezmoi source-path + $ chezmoi source-path ~/.bashrc + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/state.md b/assets/chezmoi.io/docs/reference/commands/state.md new file mode 100644 index 00000000000..da61bba7ffc --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/state.md @@ -0,0 +1,24 @@ +# `state` + +Manipulate the persistent state. + +!!! hint + + To get a full list of subcommands run: + + ```console + $ chezmoi state help + ``` + +!!! example + + ```console + $ chezmoi state data + $ chezmoi state delete --bucket=bucket --key=key + $ chezmoi state delete-bucket --bucket=bucket + $ chezmoi state dump + $ chezmoi state get --bucket=bucket --key=key + $ chezmoi state get-bucket --bucket=bucket + $ chezmoi state set --bucket=bucket --key=key --value=value + $ chezmoi state reset + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/status.md b/assets/chezmoi.io/docs/reference/commands/status.md new file mode 100644 index 00000000000..5d9bb8ddf1e --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/status.md @@ -0,0 +1,27 @@ +# `status` + +Print the status of the files and scripts managed by chezmoi in a format +similar to [`git status`](https://git-scm.com/docs/git-status). + +The first column of output indicates the difference between the last state +written by chezmoi and the actual state. The second column indicates the +difference between the actual state and the target state, and what effect +running [`chezmoi apply`](apply.md) will have. + +| Character | Meaning | First column | Second column | +| --------- | --------- | ------------------ | ---------------------- | +| Space | No change | No change | No change | +| `A` | Added | Entry was created | Entry will be created | +| `D` | Deleted | Entry was deleted | Entry will be deleted | +| `M` | Modified | Entry was modified | Entry will be modified | +| `R` | Run | Not applicable | Script will be run | + +## `-i`, `--include` *types* + +Only include entries of type *types*. + +!!! example + + ```console + $ chezmoi status + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/target-path.md b/assets/chezmoi.io/docs/reference/commands/target-path.md new file mode 100644 index 00000000000..680568f89cb --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/target-path.md @@ -0,0 +1,11 @@ +# `target-path` [*source-path*...] + +Print the target path of each source path. If no source paths are specified then +print the target directory. + +!!! example + + ```console + $ chezmoi target-path + $ chezmoi target-path ~/.local/share/chezmoi/dot_zshrc + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/unmanage.md b/assets/chezmoi.io/docs/reference/commands/unmanage.md new file mode 100644 index 00000000000..cf086d7c93f --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/unmanage.md @@ -0,0 +1,3 @@ +# `unmanage` *target*... + +`unmanage` is an alias for `forget` for symmetry with `manage`. diff --git a/assets/chezmoi.io/docs/reference/commands/unmanaged.md b/assets/chezmoi.io/docs/reference/commands/unmanaged.md new file mode 100644 index 00000000000..5c711644bf7 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/unmanaged.md @@ -0,0 +1,18 @@ +# `unmanaged` [*path*...] + +List all unmanaged files in *path*s. When no *path*s are supplied, list all +unmanaged files in the destination directory. + +It is an error to supply *path*s that are not found on the filesystem. + +## `-p`, `--path-style` `absolute`|`relative` + +Print paths in the given style. Relative paths are relative to the destination +directory. The default is `relative`. + +!!! example + + ```console + $ chezmoi unmanaged + $ chezmoi unmanaged ~/.config/chezmoi ~/.ssh + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/update.md b/assets/chezmoi.io/docs/reference/commands/update.md new file mode 100644 index 00000000000..7d53974e7ed --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/update.md @@ -0,0 +1,22 @@ +# `update` + +Pull changes from the source repo and apply any changes. + +If `update.command` is set then chezmoi will run `update.command` with +`update.args` in the working tree. Otherwise, chezmoi will run `git pull +--autostash --rebase [--recurse-submodules]` , using chezmoi's builtin git if +`useBuiltinGit` is `true` or if `git.command` cannot be found in `$PATH`. + +## `-i`, `--include` *types* + +Only update entries of type *types*. + +## `--recurse-submodules` *bool* + +Update submodules recursively. This defaults to `true`. + +!!! example + + ```console + $ chezmoi update + ``` diff --git a/assets/chezmoi.io/docs/reference/commands/upgrade.md b/assets/chezmoi.io/docs/reference/commands/upgrade.md new file mode 100644 index 00000000000..99a39e44d84 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/upgrade.md @@ -0,0 +1,18 @@ +# `upgrade` + +Upgrade chezmoi by downloading and installing the latest released version. This +will call the GitHub API to determine if there is a new version of chezmoi +available, and if so, download and attempt to install it in the same way as +chezmoi was previously installed. + +If the any of the `$CHEZMOI_GITHUB_ACCESS_TOKEN`, `$CHEZMOI_GITHUB_TOKEN`, +`$GITHUB_ACCESS_TOKEN`, or `$GITHUB_TOKEN` environment variables are set, then +the first value found will be used to authenticate requests to the GitHub API, +otherwise unauthenticated requests are used which are subject to stricter [rate +limiting](https://developer.github.com/v3/#rate-limiting). Unauthenticated +requests should be sufficient for most cases. + +!!! warning + + If you installed chezmoi using a package manager, the `upgrade` command + might have been removed by the package maintainer. diff --git a/assets/chezmoi.io/docs/reference/commands/verify.md b/assets/chezmoi.io/docs/reference/commands/verify.md new file mode 100644 index 00000000000..29693626308 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/commands/verify.md @@ -0,0 +1,16 @@ +# `verify` [*target*...] + +Verify that all *target*s match their target state. chezmoi exits with code 0 +(success) if all targets match their target state, or 1 (failure) otherwise. If +no targets are specified then all targets are checked. + +## `-i`, `--include` *types* + +Only include entries of type *types*. + +!!! example + + ```console + $ chezmoi verify + $ chezmoi verify ~/.bashrc + ``` diff --git a/assets/chezmoi.io/docs/reference/concepts.md b/assets/chezmoi.io/docs/reference/concepts.md new file mode 100644 index 00000000000..b68b5cb08fe --- /dev/null +++ b/assets/chezmoi.io/docs/reference/concepts.md @@ -0,0 +1,30 @@ +# Concepts + +chezmoi computes the target state for the current machine and then updates the +destination directory, where: + +* The *destination directory* is the directory that chezmoi manages, usually + your home directory, `~`. + +* A *target* is a file, directory, or symlink in the destination directory. + +* The *destination state* is the current state of all the targets in the + destination directory. + +* The *source state* declares the desired state of your home directory, + including templates that use machine-specific data. It contains only regular + files and directories. + +* The *source directory* is where chezmoi stores the source state. By default + it is `~/.local/share/chezmoi`. + +* The *config file* contains machine-specific data. By default it is + `~/.config/chezmoi/chezmoi.toml`. + +* The *target state* is the desired state of the destination directory. It is + computed from the source state, the config file, and the destination state. + The target state includes regular files and directories, and may also include + symbolic links, scripts to be run, and targets to be removed. + +* The *working tree* is the git working tree. Normally it is the same as the + source directory, but can be a parent of the source directory. diff --git a/assets/chezmoi.io/docs/reference/configuration-file/editor.md b/assets/chezmoi.io/docs/reference/configuration-file/editor.md new file mode 100644 index 00000000000..2259462bf8c --- /dev/null +++ b/assets/chezmoi.io/docs/reference/configuration-file/editor.md @@ -0,0 +1,13 @@ +# Editor + +The editor used is the first non-empty string of the `edit.command` +configuration variable, the `$VISUAL` environment variable, the `$EDITOR` +environment variable. If none are set then chezmoi falls back to `notepad.exe` +on Windows systems and `vi` on non-Windows systems. + +When the `edit.command` configuration variable is used, extra arguments can be +passed to the editor with the `edit.args` configuration variable. + +chezmoi will emit a warning if the editor returns in less than +`edit.minDuration` (default `1s`). To disable this warning, set +`edit.minDuration` to `0`. diff --git a/assets/chezmoi.io/docs/reference/configuration-file/hooks.md b/assets/chezmoi.io/docs/reference/configuration-file/hooks.md new file mode 100644 index 00000000000..cc03d6112b9 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/configuration-file/hooks.md @@ -0,0 +1,36 @@ +# Hooks + +Hook commands are executed before and after events. Unlike scripts, hooks are +always run, even if `--dry-run` is specified. Hooks should be fast and +idempotent. + +The following events are defined: + +| Event | Trigger | +| --------------------- | --------------------------------------------- | +| *command*, e.g. `add` | Running `chezmoi command`, e.g. `chezmoi add` | +| `read-source-state` | Reading the source state | + +Each event can have a `.pre` and/or a `.post` command. The *event*.`pre` command +is executed before *event* occurs and the *event*`.post` command is executed +after *event* has occurred. + + A command contains a `command` and an optional array of strings `args`. + +!!! example + + ```toml title="~/.config/chezmoi/chezmoi.toml" + [hooks.read-source-state.pre] + command = "echo" + args = ["pre-read-source-state-hook"] + + [hooks.apply.post] + command = "echo" + args = ["post-apply-hook"] + ``` + +When running hooks, the `CHEZMOI=1` and `CHEZMOI_*` environment variables will +be set. `CHEZMOI_COMMAND` is set to the chezmoi command being run, +`CHEZMOI_COMMAND_DIR` is set to the directory where chezmoi was run from, and +`CHEZMOI_ARGS` contains the full arguments to chezmoi, starting with the path to +chezmoi's executable. diff --git a/assets/chezmoi.io/docs/reference/configuration-file/index.md b/assets/chezmoi.io/docs/reference/configuration-file/index.md new file mode 100644 index 00000000000..c3f37fc2896 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/configuration-file/index.md @@ -0,0 +1,57 @@ +# Configuration file + +chezmoi searches for its configuration file according to the [XDG Base Directory +Specification](https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html) +and supports [JSON](https://www.json.org/json-en.html), JSONC, +[TOML](https://github.com/toml-lang/toml), and [YAML](https://yaml.org/). The +basename of the config file is `chezmoi`. If multiple configuration file formats +are present, chezmoi will report an error. + +In most installations, the config file will be read from +`$HOME/.config/chezmoi/chezmoi.$FORMAT` +(`%USERPROFILE%/.config/chezmoi/chezmoi.$FORMAT`), where `$FORMAT` is one of +`json`, `jsonc`, `toml`, or `yaml`. The config file can be set explicitly with +the `--config` command line option. By default, the format is detected based on +the extension of the config file name, but can be overridden with the +`--config-format` command line option. + +## Examples + +=== "JSON" + + ```json title="~/.config/chezmoi/chezmoi.json" + { + "sourceDir": "/home/user/.dotfiles", + "git": { + "autoPush": true + } + } + ``` + +=== "JSONC" + + ```jsonc title="~/.config/chezmoi/chezmoi.jsonc" + { + // The chezmoi source files are stored here + "sourceDir": "/home/user/.dotfiles", + "git": { + "autoPush": true + } + } + ``` + +=== "TOML" + + ```toml title="~/.config/chezmoi/chezmoi.toml" + sourceDir = "/home/user/.dotfiles" + [git] + autoPush = true + ``` + +=== "YAML" + + ```yaml title="~/.config/chezmoi/chezmoi.yaml" + sourceDir: /home/user/.dotfiles + git: + autoPush: true + ``` diff --git a/assets/chezmoi.io/docs/reference/configuration-file/pinentry.md b/assets/chezmoi.io/docs/reference/configuration-file/pinentry.md new file mode 100644 index 00000000000..bebc53c5f6e --- /dev/null +++ b/assets/chezmoi.io/docs/reference/configuration-file/pinentry.md @@ -0,0 +1,23 @@ +# pinentry + +By default, chezmoi will request passwords from the terminal. + +If the `--no-tty` option is passed, then chezmoi will instead read passwords +from the standard input. + +Otherwise, if the configuration variable `pinentry.command` is set then chezmoi +will instead used the given command to read passwords, assuming that it follows +the [Assuan protocol](https://www.gnupg.org/documentation/manuals/assuan.pdf) +like [GnuPG's +pinentry](https://www.gnupg.org/related_software/pinentry/index.html). The +configuration variable `pinentry.args` specifies extra arguments to be passed +to `pinentry.command` and the configuration variable `pinentry.options` +specifies extra options to be set. The default `pinentry.options` is +`["allow-external-password-cache"]`. + +!!! example + + ```toml title="~/.config/chezmoi/chezmoi.toml" + [pinentry] + command = "pinentry" + ``` diff --git a/assets/chezmoi.io/docs/reference/configuration-file/textconv.md b/assets/chezmoi.io/docs/reference/configuration-file/textconv.md new file mode 100644 index 00000000000..63812b2da0d --- /dev/null +++ b/assets/chezmoi.io/docs/reference/configuration-file/textconv.md @@ -0,0 +1,21 @@ +# textconv + +A section called `textconv` in the configuration file controls how file contents +are modified before being passed to diff. + +The `textconv` must contain an array of objects where each object has the +following properties: + +| Name | Type | Description | +| --------- | -------- | ----------------------------- | +| `pattern` | string | Target path pattern to match | +| `command` | string | Command to transform contents | +| `args` | []string | Extra arguments to command | + +Files whose target path matches `pattern` are transformed by passing them to the +standard input of `command` with `args`, and new contents are read from the +command's standard output. + +If a target path does not match any patterns then the file contents are passed +unchanged to diff. If a target path matches multiple patterns then element with +the longest `pattern` is used. diff --git a/assets/chezmoi.io/docs/reference/configuration-file/umask.md b/assets/chezmoi.io/docs/reference/configuration-file/umask.md new file mode 100644 index 00000000000..54e00994dfe --- /dev/null +++ b/assets/chezmoi.io/docs/reference/configuration-file/umask.md @@ -0,0 +1,15 @@ +# umask + +By default, chezmoi uses your current umask as set by your operating system and +shell. chezmoi only stores crude permissions in its source state, namely in the +`executable` and `private` attributes, corresponding to the umasks of `0o111` +and `0o077` respectively. + +For machine-specific control of umask, set the `umask` configuration variable in +chezmoi's configuration file. + +!!! example + + ```toml title="~/.config/chezmoi/chezmoi.toml" + umask = 0o22 + ``` diff --git a/assets/chezmoi.io/docs/reference/configuration-file/variables.md.tmpl b/assets/chezmoi.io/docs/reference/configuration-file/variables.md.tmpl new file mode 100644 index 00000000000..0592db14319 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/configuration-file/variables.md.tmpl @@ -0,0 +1,33 @@ +# Variables + +The following configuration variables are available: + +| Section | Variable | Type | Default value | Description | +| ------- | -------- | ---- | ------------- | ----------- | +{{ $lastSectionValue := "" -}} +{{ range $sectionName, $variables := .sections -}} +{{ $sectionValue := "" -}} +{{ if $sectionName -}} +{{ $sectionValue = printf "`%s`" $sectionName -}} +{{ else -}} +{{ $sectionValue = "Top level" -}} +{{ end -}} +{{ range $variableName, $variable := $variables -}} +{{ if eq $sectionValue $lastSectionValue -}} +{{ $sectionValue = "" -}} +{{ else -}} +{{ $lastSectionValue = $sectionValue -}} +{{ end -}} +{{ with $variable -}} +{{ $nameValue := $variableName -}} +{{ if regexMatch "\\A\\w+\\z" $nameValue -}} +{{ $nameValue = printf "`%s`" $nameValue -}} +{{ end -}} +{{ $defaultValue := "*none*" -}} +{{ if eq .type "bool" -}} +{{ $defaultValue = "`false`" -}} +{{ end -}} +| {{ $sectionValue }} | {{ $nameValue }} | {{ .type | default "string" }} | {{ .default | default $defaultValue }} | {{ .description }} | +{{ end -}} +{{ end -}} +{{ end }} diff --git a/assets/chezmoi.io/docs/reference/configuration-file/variables.md.yaml b/assets/chezmoi.io/docs/reference/configuration-file/variables.md.yaml new file mode 100644 index 00000000000..1367817f63b --- /dev/null +++ b/assets/chezmoi.io/docs/reference/configuration-file/variables.md.yaml @@ -0,0 +1,421 @@ +sections: + '': + cacheDir: + default: >- + `$XDG_CACHE_HOME/chezmoi`
+ `$HOME/.cache/chezmoi`
+ `%USERPROFILE%/.cache/chezmoi` + description: Cache directory + color: + default: '`auto`' + description: Colorize output + data: + type: object + description: Template data + destDir: + default: >- + `$HOME`
+ `%USERPROFILE%` + description: Destination directory + encryption: + description: Encryption type, either `age` or `gpg` + env: + type: object + description: Extra environment variables for scripts and commands + format: + default: '`json`' + description: Format for data output, either `json` or `yaml` + mode: + default: '`file`' + description: Mode in target dir, either `file` or `symlink` + pager: + default: '`$PAGER`' + description: Default pager CLI command + persistentState: + default: >- + `$XDG_CONFIG_HOME/chezmoi/chezmoi.boltdb`
+ `$HOME/.config/chezmoi/chezmoi.boltdb`
+ `%USERPROFILE%/.config/chezmoi/chezmoi.boltdb` + description: Location of the persistent state file + progress: + type: bool + description: Display progress bars + scriptEnv: + type: object + description: Extra environment variables for scripts and commands + scriptTempDir: + description: Temporary directory for scripts + sourceDir: + default: >- + `$XDG_SHARE_HOME/chezmoi`
+ `$HOME/.local/share/chezmoi`
+ `%USERPROFILE%/.local/share/chezmoi` + description: Source directory + tempDir: + default: '*from system*' + description: Temporary directory + umask: + type: int + default: '*from system*' + description: Umask + useBuiltinAge: + default: '`auto`' + description: Use builtin age if `age` command is not found in `$PATH` + useBuiltinGit: + default: '`auto`' + description: Use builtin git if `git` command is not found in `$PATH` + verbose: + type: bool + description: Make output more verbose + workingTree: + default: '*source directory*' + description: git working tree directory + add: + encrypt: + type: bool + description: Encrypt by default + secrets: + default: '`warning`' + description: Action when secrets are found when adding files + templateSymlinks: + type: bool + description: Template symlinks to source and home dirs + age: + args: + type: '[]string' + description: Extra args to age CLI command + command: + default: '`age`' + description: age CLI command + identity: + description: age identity file + identities: + type: '[]string' + description: age identity files + passphrase: + type: bool + description: Use age passphrase instead of identity + recipient: + description: age recipient + recipients: + type: '[]string' + description: age recipients + recipientsFile: + description: age recipients file + recipientsFiles: + type: '[]string' + description: age recipients files + suffix: + default: '`.age`' + description: Suffix appended to age-encrypted files + symmetric: + type: bool + description: Use age symmetric encryption + awsSecretsManager: + profile: + description: AWS shared profile name + region: + description: AWS region + azureKeyVault: + defaultVault: + description: Default Azure Key Vault name + bitwarden: + command: + default: '`bw`' + description: Bitwarden CLI command + bitwardenSecrets: + command: + default: '`bws`' + description: Bitwarden Secrets CLI command + cd: + args: + type: '[]string' + description: Extra args to shell in `cd` command + command: + description: Shell to run in `cd` command + completion: + custom: + type: bool + description: Enable custom shell completions + dashlane: + args: + type: '[]string' + description: Extra args to Dashlane CLI command + command: + default: '`dcli`' + description: Dashlane CLI command + diff: + args: + type: '[]string' + default: '*see [`diff`](../../user-guide/tools/diff.md)*' + description: Extra args to external diff command + command: + description: External diff command + exclude: + type: '[]string' + description: Entry types to exclude from diffs + pager: + description: Diff-specific pager + reverse: + type: bool + description: Reverse order of arguments to diff + scriptContents: + type: bool + default: '`true`' + description: Show script contents + doppler: + args: + type: '[]string' + description: Extra args to Doppler CLI command + command: + default: '`doppler`' + description: Doppler CLI command + config: + type: string + description: Default config (aka environment) if none is specified + project: + type: string + description: Default project name if none is specified + edit: + apply: + type: bool + description: Apply changes on exit + args: + type: '[]string' + description: Extra args to edit command + command: + default: '`$EDITOR` / `$VISUAL`' + description: Edit command + hardlink: + type: bool + default: '`true`' + description: Invoke editor with a hardlink to the source file + minDuration: + type: duration + default: '`1s`' + description: Minimum duration for edit command + watch: + type: bool + description: Automatically apply changes when files are saved + ejson: + keyDir: + type: string + description: Path to directory containing private keys. Defaults to /opt/ejson/keys. Setting the EJSON_KEYDIR environment will also set this value, with lower precedence. + key: + type: string + description: The private key to use for decryption, will supersede using the keyDir if set. + git: + autoAdd: + type: bool + description: Add changes to the source state after any change + autoCommit: + type: bool + description: Commit changes to the source state after any change + autoPush: + type: bool + description: Push changes to the source state after any change + command: + default: '`git`' + description: git CLI command + commitMessageTemplate: + type: string + description: Commit message template + commitMessageTemplateFile: + type: string + description: Commit message template file (relative to source directory) + gitHub: + refreshPeriod: + type: duration + default: '`1m`' + description: Minimum duration between identical GitHub API requests + gopass: + command: + default: '`gopass`' + description: gopass CLI command + gpg: + args: + type: '[]string' + description: Extra args to GPG CLI command + command: + default: '`gpg`' + description: GPG CLI command + recipient: + description: GPG recipient + recipients: + type: '[]string' + description: GPG recipients + suffix: + default: '`.asc`' + description: Suffix appended to GPG-encrypted files + symmetric: + type: bool + description: Use symmetric GPG encryption + hcpVaultSecrets: + applicationName: + type: string + description: Default application name if none is specified + args: + type: '[]string' + description: Extra args to HCP Vault Secrets CLI command + command: + default: '`vlt`' + description: HCP Vault Secrets CLI command + organizationId: + type: string + description: Default organization ID if none is specified + projectId: + type: string + description: Default project ID if none is specified + hooks: + '*command*`.post.args`': + type: '[]string' + description: Extra arguments to command to run after *command* + '*command*`.post.command`': + type: '[]string' + description: Command to run after *command* + '*command*`.pre.args`': + type: '[]string' + description: Extra arguments to command to run before *command* + '*command*`.pre.command`': + type: '[]string' + description: Command to run before *command* + interpreters: + '*extension*.`args`': + type: '[]string' + description: See [Scripts on Windows](../target-types.md#scripts-on-windows) + '*extension*.`command`': + default: '*special*' + description: See [Scripts on Windows](../target-types.md#scripts-on-windows) + keepassxc: + args: + type: '[]string' + description: Extra args to KeePassXC CLI command + command: + default: '`keepassxc-cli`' + description: KeePassXC CLI command + database: + description: KeePassXC database + mode: + default: '`cache-password`' + description: See [KeePassXC functions](../templates/keepassxc-functions/index.md) + prompt: + type: bool + default: '`true`' + description: Prompt for password + keeper: + args: + type: '[]string' + description: Extra args to Keeper CLI command + command: + default: '`keeper`' + description: Keeper CLI command + lastpass: + command: + default: '`lpass`' + description: LastPass CLI command + merge: + args: + type: '[]string' + default: See [`merge`](../../user-guide/tools/merge.md) + description: Extra args to three-way merge CLI command + command: + description: Three-way merge CLI command + onepassword: + cache: + type: bool + default: '`true`' + description: Enable optional caching provided by `op` + command: + default: '`op`' + description: 1Password CLI command + prompt: + type: bool + default: '`true`' + description: Prompt for sign-in when no valid session is available + mode: + default: '`account`' + description: See [1Password Secrets Automation](../../user-guide/password-managers/1password.md#secrets-automation) + onepasswordSDK: + token: + description: See [1Password SDK functions](../templates/1password-sdk-functions/index.md) + tokenEnvVar: + description: See [1Password SDK functions](../templates/1password-sdk-functions/index.md) + pass: + command: + default: '`pass`' + description: Pass CLI command + passhole: + args: + type: '[]string' + description: Extra args to Passhole CLI command + command: + default: '`ph`' + description: Passhole CLI command + prompt: + type: bool + default: '`true`' + description: Prompt for password + pinentry: + args: + type: '[]string' + description: Extra args to pinentry CLI command + command: + description: pinentry CLI command + options: + type: '[]string' + default: See [`pinentry`](./pinentry.md) + description: Extra options for pinentry + rbw: + command: + default: '`rbw`' + description: Unofficial Bitwarden CLI command + secret: + args: + type: '[]string' + description: Extra args to secret CLI command + command: + description: Generic secret CLI command + status: + exclude: + type: '[]string' + description: Entry types to exclude from status + pathStyle: + type: string + default: '`relative`' + description: How to present the path to files in status output + template: + options: + type: '[]string' + default: '`["missingkey=error"]`' + description: Template options + textconv: + '': + type: '[]object' + description: See [textconv](./textconv.md) + vault: + command: + default: '`vault`' + description: Vault CLI command + update: + apply: + type: bool + default: '`true`' + description: Apply after pulling + args: + type: '[]string' + description: Extra args to update command + command: + description: Update command + recurseSubmodules: + type: bool + default: '`true`' + description: Update submodules recursively + verify: + exclude: + type: '[]string' + description: Entry types to exclude from verify + warnings: + '': + type: object + description: See [Warnings](./warnings.md) + diff --git a/assets/chezmoi.io/docs/reference/configuration-file/warnings.md b/assets/chezmoi.io/docs/reference/configuration-file/warnings.md new file mode 100644 index 00000000000..52fe5dffad1 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/configuration-file/warnings.md @@ -0,0 +1,16 @@ +# Warnings + +By default, chezmoi will warn you when it encounters potential problems. Some of +these warnings can be suppressed by setting values in configuration file. + +| Variable | Type | Default | Description | +| ------------------------------ | ---- | ------- | ---------------------------------------------- | +| `configFileTemplateHasChanged` | bool | `true` | Warn when the config file template has changed | + + +!!! example + + ```toml title="~/.config/chezmoi/chezmoi.toml" + [warnings] + configFileTemplateHasChanged = false + ``` diff --git a/assets/chezmoi.io/docs/reference/index.md b/assets/chezmoi.io/docs/reference/index.md new file mode 100644 index 00000000000..7907d94fb4a --- /dev/null +++ b/assets/chezmoi.io/docs/reference/index.md @@ -0,0 +1,3 @@ +# Reference + +Manage your dotfiles across multiple machines, securely. diff --git a/assets/chezmoi.io/docs/reference/plugins.md b/assets/chezmoi.io/docs/reference/plugins.md new file mode 100644 index 00000000000..e0bdcd684a8 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/plugins.md @@ -0,0 +1,8 @@ +# Plugins + +chezmoi supports plugins, similar to git. + +If you run `chezmoi command` where *command* is not a builtin chezmoi command +then chezmoi will look for a binary called `chezmoi-command` in your `$PATH`. If +such a binary is found then chezmoi will execute it. Otherwise, chezmoi will +report an unknown command error. diff --git a/assets/chezmoi.io/docs/reference/release-history.md.tmpl b/assets/chezmoi.io/docs/reference/release-history.md.tmpl new file mode 100644 index 00000000000..235e6cd487d --- /dev/null +++ b/assets/chezmoi.io/docs/reference/release-history.md.tmpl @@ -0,0 +1,35 @@ +# Release history + +{{- $releases := gitHubListReleases "twpayne/chezmoi" }} +{{- $latestRelease := index $releases 0 }} + +[Upcoming changes](https://github.com/twpayne/chezmoi/compare/{{ $latestRelease.Name }}...master) + +{{- $lastReleaseIndex := sub (len $releases) 1 }} +{{- range $index, $release := $releases }} + +## [{{ $release.Name | trimPrefix "v" }}]({{ $release.HTMLURL }}) ({{ $release.PublishedAt | gitHubTimestampFormat "2006-01-02" }}) + +{{ $release.Body + | replaceAllRegex "(?m)^#+ (Changelog\\r?|What's Changed)$" "" + | replaceAllRegex "(?m)^#+ (.*)\\r?$" "\n\n$1\n" + | replaceAllRegex "\\*\\*(Full Changelog)\\*\\*.*\n" "" + | replaceAllRegex "(https://github\\.com/twpayne/chezmoi/compare/(\\S+))" "[`$2`]($1)" + | replaceAllRegex "@(\\S+)" "[**$0**](https://github.com/$1)" + | replaceAllRegex "pull request #(\\d+)" "pull request https://github.com/twpayne/chezmoi/pull/$1" + | replaceAllRegex "\\(#(\\d+)[)]" "(https://github.com/twpayne/chezmoi/pull/$1)" + | replaceAllRegex "(https://github\\.com/twpayne/chezmoi/pull/(\\d+))" "[#$2]($1)" + | replaceAllRegex "(?m)^([0-9a-f]{7,})" "* $1" + | replaceAllRegex "(?m)^\\* ([0-9a-f]{7,})" "* [`$1`](https://github.com/twpayne/chezmoi/commit/$1)" + | replaceAllRegex "\\r\\n" "\\n" + | trim + | default "Internal changes only" +}} + +{{- if ne $index $lastReleaseIndex }} +{{- $prevRelease := index $releases (add $index 1) }} + +Full changelog: [{{ $prevRelease.Name }}...{{ $release.Name }}](https://github.com/twpayne/chezmoi/compare/{{ $prevRelease.Name }}...{{ $release.Name }}) +{{- end }} + +{{- end }} diff --git a/assets/chezmoi.io/docs/reference/source-state-attributes.md b/assets/chezmoi.io/docs/reference/source-state-attributes.md new file mode 100644 index 00000000000..6ac1811a291 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/source-state-attributes.md @@ -0,0 +1,64 @@ +# Source state attributes + +chezmoi stores the source state of files, symbolic links, and directories in +regular files and directories in the source directory (`~/.local/share/chezmoi` +by default). This location can be overridden with the `-S` flag or by giving a +value for `sourceDir` in the configuration file. Directory targets are +represented as directories in the source state. All other target types are +represented as files in the source state. Some state is encoded in the source +names. + +The following prefixes and suffixes are special, and are collectively referred +to as "attributes": + +| Prefix | Effect | +| ------------- | ----------------------------------------------------------------------------------- | +| `after_` | Run script after updating the destination | +| `before_` | Run script before updating the destination | +| `create_` | Ensure that the file exists, and create it with contents if it does not | +| `dot_` | Rename to use a leading dot, e.g. `dot_foo` becomes `.foo` | +| `empty_` | Ensure the file exists, even if is empty. By default, empty files are removed | +| `encrypted_` | Encrypt the file in the source state | +| `external_` | Ignore attributes in child entries | +| `exact_` | Remove anything not managed by chezmoi | +| `executable_` | Add executable permissions to the target file | +| `literal_` | Stop parsing prefix attributes | +| `modify_` | Treat the contents as a script that modifies an existing file | +| `once_` | Only run the script if its contents have not been run before | +| `onchange_` | Only run the script if its contents have not been run before with the same filename | +| `private_` | Remove all group and world permissions from the target file or directory | +| `readonly_` | Remove all write permissions from the target file or directory | +| `remove_` | Remove the file or symlink if it exists or the directory if it is empty | +| `run_` | Treat the contents as a script to run | +| `symlink_` | Create a symlink instead of a regular file | + +| Suffix | Effect | +| ---------- | --------------------------------------------------- | +| `.literal` | Stop parsing suffix attributes | +| `.tmpl` | Treat the contents of the source file as a template | + +Different target types allow different prefixes and suffixes. The order of +prefixes is important. + +| Target type | Source type | Allowed prefixes in order | Allowed suffixes | +| ---------------- | ----------- | --------------------------------------------------------------------------------- | ---------------- | +| Directory | Directory | `remove_`, `external_`, `exact_`, `private_`, `readonly_`, `dot_` | *none* | +| Regular file | File | `encrypted_`, `private_`, `readonly_`, `empty_`, `executable_`, `dot_` | `.tmpl` | +| Create file | File | `create_`, `encrypted_`, `private_`, `readonly_`, `empty_`, `executable_`, `dot_` | `.tmpl` | +| Modify file | File | `modify_`, `encrypted_`, `private_`, `readonly_`, `executable_`, `dot_` | `.tmpl` | +| Remove file | File | `remove_`, `dot_` | *none* | +| Script | File | `run_`, `once_` or `onchange_`, `before_` or `after_` | `.tmpl` | +| Symbolic link | File | `symlink_`, `dot_` | `.tmpl` | + +The `literal_` prefix and `.literal` suffix can appear anywhere and stop +attribute parsing. This permits filenames that would otherwise conflict with +chezmoi's attributes to be represented. + +In addition, if the source file is encrypted, the suffix `.age` (when age +encryption is used) or `.asc` (when gpg encryption is used) is stripped. These +suffixes can be overridden with the `age.suffix` and `gpg.suffix` configuration +variables. + +chezmoi ignores all files and directories in the source directory that begin +with a `.` with the exception of files and directories that begin with +`.chezmoi`. diff --git a/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoi-format-tmpl.md b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoi-format-tmpl.md new file mode 100644 index 00000000000..fe3c7c7d3db --- /dev/null +++ b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoi-format-tmpl.md @@ -0,0 +1,16 @@ +# `.chezmoi.$FORMAT.tmpl` + +If a file called `.chezmoi.$FORMAT.tmpl` exists then `chezmoi init` will use it +to create an initial config file. `$FORMAT` must be one of the supported config +file formats, e.g. `json`, `jsonc`, `toml`, or `yaml`. Templates defined in +`.chezmoitemplates` are not available because the template is executed before +the source state is read. + +!!! example + + ``` title="~/.local/share/chezmoi/.chezmoi.yaml.tmpl" + {{ $email := promptString "email" -}} + + data: + email: {{ $email | quote }} + ``` diff --git a/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoidata-format.md b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoidata-format.md new file mode 100644 index 00000000000..b685fd03c63 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoidata-format.md @@ -0,0 +1,27 @@ +# `.chezmoidata` and `.chezmoidata.$FORMAT` + +If a file called `.chezmoidata.$FORMAT` exists in the source state, it is +interpreted as template data in the given format. + +If a directory called `.chezmoidata` exists in the source state, then all files +in it are interpreted as template data in the format given by their extension. + +!!! example + + If `.chezmoidata.toml` contains the following: + + ```toml title="~/.local/share/chezmoi/.chezmoidata.toml" + fontSize = 12 + ``` + + Then the `.fontSize` variable is available in templates, e.g. + + ``` + FONT_SIZE={{ .fontSize }} + ``` + + Will result in: + + ``` + FONT_SIZE=12 + ``` diff --git a/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiexternal-format.md b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiexternal-format.md new file mode 100644 index 00000000000..16d389ae2ae --- /dev/null +++ b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiexternal-format.md @@ -0,0 +1,159 @@ +# `.chezmoiexternal.$FORMAT{,.tmpl}` + +If a file called `.chezmoiexternal.$FORMAT` (with an optional `.tmpl` extension) +exists in the source state (either `~/.local/share/chezmoi` or directory defined +inside `.chezmoiroot`), it is interpreted as a list of external files and +archives to be included as if they were in the source state. + +`$FORMAT` must be one of chezmoi's supported configuration file formats, e.g. +`json`, `jsonc`, `toml`, or `yaml`. + +`.chezmoiexternal.$FORMAT` is interpreted as a template. This allows different +externals to be included on different machines. + +Entries are indexed by target name relative to the directory of the +`.chezmoiexternal.$FORMAT` file, and must have a `type` and a `url` field. +`type` can be either `file`, `archive`, `archive-file`, or `git-repo`. If the +entry's parent directories do not already exist in the source state then chezmoi +will create them as regular directories. + +Entries may have the following fields: + +| Variable | Type | Default value | Description | +| ---------------------------- | -------- | ------------- | ---------------------------------------------------------------- | +| `type` | string | *none* | External type (`file`, `archive`, `archive-file`, or `git-repo`) | +| `decompress` | string | *none* | Decompression for file | +| `encrypted` | bool | `false` | Whether the external is encrypted | +| `exact` | bool | `false` | Add `exact_` attribute to directories in archive | +| `exclude` | []string | *none* | Patterns to exclude from archive | +| `executable` | bool | `false` | Add `executable_` attribute to file | +| `private` | bool | `false` | Add `private_` attribute to file | +| `readonly` | bool | `false` | Add `readonly_` attribute to file | +| `format` | string | *autodetect* | Format of archive | +| `path` | string | *none* | Path to file in archive | +| `include` | []string | *none* | Patterns to include from archive | +| `refreshPeriod` | duration | `0` | Refresh period | +| `stripComponents` | int | `0` | Number of leading directory components to strip from archives | +| `url` | string | *none* | URL | +| `checksum.sha256` | string | *none* | Expected SHA256 checksum of data | +| `checksum.sha384` | string | *none* | Expected SHA384 checksum of data | +| `checksum.sha512` | string | *none* | Expected SHA512 checksum of data | +| `checksum.size` | int | *none* | Expected size of data | +| `clone.args` | []string | *none* | Extra args to `git clone` | +| `filter.command` | string | *none* | Command to filter contents | +| `filter.args` | []string | *none* | Extra args to command to filter contents | +| `pull.args` | []string | *none* | Extra args to `git pull` | +| `archive.extractAppleDouble` | bool | `false` | If `true`, AppleDouble files are extracted | + +If any of the optional `checksum.sha256`, `checksum.sha384`, or +`checksum.sha512` fields are set, chezmoi will verify that the downloaded data +has the given checksum. + +The optional boolean `encrypted` field specifies whether the file or archive is +encrypted. + +The optional string `decompress` specifies how the file should be decompressed. +Supported compression formats are `bzip2`, `gzip`, `xz`, and `zstd`. Note the +`.zip` files are archives and you must use the `archive-file` type to extract a +single file from a `.zip` archive. + +If optional string `filter.command` and array of strings `filter.args` are +specified, the file or archive is filtered by piping it into the command's +standard input and reading the command's standard output. + +If `type` is `file` then the target is a file with the contents of `url`. The +optional boolean field `executable` may be set, in which case the target file +will be executable. + +If `type` is `archive` then the target is a directory with the contents of the +archive at `url`. The optional boolean field `exact` may be set, in which case +the directory and all subdirectories will be treated as exact directories, i.e. +`chezmoi apply` will remove entries not present in the archive. The optional +integer field `stripComponents` will remove leading path components from the +members of archive. The optional string field `format` sets the archive format. +The supported archive formats are `tar`, `tar.gz`, `tgz`, `tar.bz2`, `tbz2`, +`xz`, `.tar.zst`, and `zip`. If `format` is not specified then chezmoi will +guess the format using firstly the path of the URL and secondly its contents. + +When `type` is `archive` or `archive-file`, the optional setting +`archive.extractAppleDouble` controls whether +[AppleDouble](https://en.wikipedia.org/wiki/AppleSingle_and_AppleDouble_formats) +files are extracted. It is `false` by default, so AppleDouble files will not +be extracted. + +The optional `include` and `exclude` fields are lists of patterns specify which +archive members to include or exclude respectively. Patterns match paths in the +archive, not the target state. chezmoi uses the following algorithm to +determine whether an archive member is included: + +1. If the archive member name matches any `exclude` pattern, then the archive + member is excluded. In addition, if the archive member is a directory, then + all contained files and sub-directories will be excluded, too (recursively). +2. Otherwise, if the archive member name matches any `include` pattern, then + the archive member is included. +3. Otherwise, if only `include` patterns were specified then the archive member + is excluded. +4. Otherwise, if only `exclude` patterns were specified then the archive member + is included. +5. Otherwise, the archive member is included.o + +Excluded archive members do not generate source state entries, and, if they are +directories, all of their children are also excluded. + +If `type` is `archive-file` then the target is a file or symlink with the +contents of the entry `path` in the archive at `url`. The optional integer field +`stripComponents` will remove leading path components from the members of the +archive before comparing them with `path`. The behavior of `format` is the same +as for `archive`. If `executable` is `true` then chezmoi will set the executable +bits on the target file, even if they are not set in the archive. + +If `type` is `git-repo` then chezmoi will run `git clone $URL $TARGET_NAME` +with the optional `clone.args` if the target does not exist. If the target +exists, then chezmoi will run `git pull` with the optional `pull.args` to +update the target. + +For `file` and `archive` externals, chezmoi will cache downloaded URLs. The +optional duration `refreshPeriod` field specifies how often chezmoi will +re-download the URL. The default is zero meaning that chezmoi will never +re-download unless forced. To force chezmoi to re-download URLs, pass the +`-R`/`--refresh-externals` flag. Suitable refresh periods include one day +(`24h`), one week (`168h`), or four weeks (`672h`). + +!!! example + + ```toml title="~/.local/share/chezmoi/.chezmoiexternal.toml" + [".vim/autoload/plug.vim"] + type = "file" + url = "https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim" + refreshPeriod = "168h" + [".oh-my-zsh"] + type = "archive" + url = "https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz" + exact = true + stripComponents = 1 + refreshPeriod = "168h" + [".oh-my-zsh/custom/plugins/zsh-syntax-highlighting"] + type = "archive" + url = "https://github.com/zsh-users/zsh-syntax-highlighting/archive/master.tar.gz" + exact = true + stripComponents = 1 + refreshPeriod = "168h" + [".oh-my-zsh/custom/themes/powerlevel10k"] + type = "archive" + url = "https://github.com/romkatv/powerlevel10k/archive/v1.15.0.tar.gz" + exact = true + stripComponents = 1 + [".local/bin/age"] + type = "archive-file" + url = "https://github.com/FiloSottile/age/releases/download/v1.1.1/age-v1.1.1-{{ .chezmoi.os }}-{{ .chezmoi.arch }}.tar.gz" + path = "age/age" + ["www/adminer/plugins"] + type = "archive" + url = "https://api.github.com/repos/vrana/adminer/tarball" + refreshPeriod = "744h" + stripComponents = 2 + include = ["*/plugins/**"] + ``` + + Some more examples can be found in the [user + guide](../../user-guide/include-files-from-elsewhere.md). diff --git a/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiexternals.md b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiexternals.md new file mode 100644 index 00000000000..d3cef028856 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiexternals.md @@ -0,0 +1,4 @@ +# `.chezmoiexternals` + +If a directory called `.chezmoiexternals` exists, then all files in this +directory are treated as `.chezmoiexternal.` files. diff --git a/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiignore.md b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiignore.md new file mode 100644 index 00000000000..34692a7c847 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiignore.md @@ -0,0 +1,41 @@ +# `.chezmoiignore{,.tmpl}` + +If a file called `.chezmoiignore` (with an optional `.tmpl` extension) exists in +the source state then it is interpreted as a set of patterns to ignore. Patterns +are matched using +[`doublestar.Match`](https://pkg.go.dev/github.com/bmatcuk/doublestar/v4#Match) +and match against the target path, not the source path. + +Patterns can be excluded by prefixing them with a `!` character. All excludes +take priority over all includes. + +Comments are introduced with the `#` character and run until the end of the +line. + +`.chezmoiignore` is interpreted as a template, whether or not it has a `.tmpl` +extension. This allows different files to be ignored on different machines. + +`.chezmoiignore` files in subdirectories apply only to that subdirectory. + +!!! example + + ``` title="~/.local/share/chezmoi/.chezmoiignore" + README.md + + *.txt # ignore *.txt in the target directory + */*.txt # ignore *.txt in subdirectories of the target directory + # but not in subdirectories of subdirectories; + # so a/b/c.txt would *not* be ignored + + backups/ # ignore the backups folder, but not its contents + backups/** # ignore the contents of backups folder but not the folder itself + + {{- if ne .email "firstname.lastname@company.com" }} + # Ignore .company-directory unless configured with a company email + .company-directory # note that the pattern is not dot_company-directory + {{- end }} + + {{- if ne .email "me@home.org" }} + .personal-file + {{- end }} + ``` diff --git a/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiremove.md b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiremove.md new file mode 100644 index 00000000000..3312bd4930c --- /dev/null +++ b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiremove.md @@ -0,0 +1,6 @@ +# `.chezmoiremove{,.tmpl}` + +If a file called `.chezmoiremove` (with an optional `.tmpl` extension) exists in +the source state then it is interpreted as a list of targets to remove. +`.chezmoiremove` is interpreted as a template, whether or not it has a `.tmpl` +extension. diff --git a/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiroot.md b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiroot.md new file mode 100644 index 00000000000..7bf27f34e79 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiroot.md @@ -0,0 +1,6 @@ +# `.chezmoiroot` + +If a file called `.chezmoiroot` exists in the root of the source directory then +the source state is read from the directory specified in `.chezmoiroot` +interpreted as a relative path to the source directory. `.chezmoiroot` is read +before all other files in the source directory. diff --git a/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiscripts.md b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiscripts.md new file mode 100644 index 00000000000..5b24128d47c --- /dev/null +++ b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiscripts.md @@ -0,0 +1,5 @@ +# `.chezmoiscripts` + +If a directory called `.chezmoiscripts` exists in the root of the source +directory then any scripts in it are executed as normal scripts without +creating a corresponding directory in the target state. diff --git a/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoitemplates.md b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoitemplates.md new file mode 100644 index 00000000000..481d3826d0e --- /dev/null +++ b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoitemplates.md @@ -0,0 +1,24 @@ +# `.chezmoitemplates` + +If a directory called `.chezmoitemplates` exists, then all files in this +directory are available as templates with a name equal to the relative path +to the `.chezmoitemplates` directory. + +The [`template` action](https://pkg.go.dev/text/template#hdr-Actions) can be +used to include these templates in another template. The value of `.` must be +set explicitly if needed, otherwise the template will be executed with `nil` +data. + +!!! example + + Given: + + ``` title="~/.local/share/chezmoi/.chezmoitemplates/foo" + {{ if true }}bar{{ end }} + ``` + + ``` title="~/.local/share/chezmoi/dot_file.tmpl" + {{ template "foo" . }} + ``` + + The target state of `.file` will be `bar`. diff --git a/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiversion.md b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiversion.md new file mode 100644 index 00000000000..30e88b23aad --- /dev/null +++ b/assets/chezmoi.io/docs/reference/special-files-and-directories/chezmoiversion.md @@ -0,0 +1,12 @@ +# `.chezmoiversion` + +If a file called `.chezmoiversion` exists, then its contents are interpreted as +a semantic version defining the minimum version of chezmoi required to +interpret the source state correctly. chezmoi will refuse to interpret the +source state if the current version is too old. + +!!! example + + ``` title="~/.local/share/chezmoi/.chezmoiversion" + 1.5.0 + ``` diff --git a/assets/chezmoi.io/docs/reference/special-files-and-directories/index.md b/assets/chezmoi.io/docs/reference/special-files-and-directories/index.md new file mode 100644 index 00000000000..e671ffe6c07 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/special-files-and-directories/index.md @@ -0,0 +1,6 @@ +# Special files and directories + +All files and directories in the source state whose name begins with `.` are +ignored by default, unless they are one of the special files listed here. +`.chezmoidata.$FORMAT` and `.chezmoitemplates` are read before all other files +so that they can be used in templates. diff --git a/assets/chezmoi.io/docs/reference/target-types.md b/assets/chezmoi.io/docs/reference/target-types.md new file mode 100644 index 00000000000..91c22df2d51 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/target-types.md @@ -0,0 +1,182 @@ +# Target types + +chezmoi will create, update, and delete files, directories, and symbolic links +in the destination directory, and run scripts. chezmoi deterministically +performs actions in ASCII order of their target name. + +!!! example + + Given a file `dot_a`, a script `run_z`, and a directory `exact_dot_c`, chezmoi + will first create `.a`, create `.c`, and then execute `run_z`. + +## Files + +Files are represented by regular files in the source state. The `encrypted_` +attribute determines whether the file in the source state is encrypted. The +`executable_` attribute will set the executable bits in the target state, +and the `private_` attribute will clear all group and world permissions. The +`readonly_` attribute will clear all write permission bits in the target state. +Files with the `.tmpl` suffix will be interpreted as templates. If the target +contents are empty then the file will be removed, unless it has an `empty_` +prefix. + +### Create file + +Files with the `create_` prefix will be created in the target state with the +contents of the file in the source state if they do not already exist. If the +file in the destination state already exists then its contents will be left +unchanged. + +### Modify file + +Files with the `modify_` prefix are treated as scripts that modify an existing +file. + +If the file contains a line with the text `chezmoi:modify-template` then that +line is removed and the rest of the script is executed template with the +existing file's contents passed as a string in `.chezmoi.stdin`. The result of +executing the template are the new contents of the file. + +Otherwise, the contents of the existing file (which maybe empty if the existing +file does not exist or is empty) are passed to the script's standard input, and +the new contents are read from the script's standard output. + +### Remove entry + +Files with the `remove_` prefix will cause the corresponding entry (file, +directory, or symlink) to be removed in the target state. + +## Directories + +Directories are represented by regular directories in the source state. The +`exact_` attribute causes chezmoi to remove any entries in the target state that +are not explicitly specified in the source state, and the `private_` attribute +causes chezmoi to clear all group and world permissions. The `readonly_` +attribute will clear all write permission bits. + +## Symbolic links + +Symbolic links are represented by regular files in the source state with the +prefix `symlink_`. The contents of the file will have a trailing newline +stripped, and the result be interpreted as the target of the symbolic link. +Symbolic links with the `.tmpl` suffix in the source state are interpreted as +templates. If the target of the symbolic link is empty or consists only of +whitespace, then the target is removed. + +## Scripts + +Scripts are represented as regular files in the source state with prefix `run_`. +The file's contents (after being interpreted as a template if it has a `.tmpl` +suffix) are executed. + +Scripts are executed on every `chezmoi apply`, unless they have the `once_` or +`onchange_` attribute. `run_once_` scripts are only executed if a script with +the same contents has not been run before, i.e. if the script is new or if its +contents have changed. `run_onchange_` scripts are executed whenever their +contents change, even if a script with the same contents has run before. + +Scripts with the `before_` attribute are executed before any files, directories, +or symlinks are updated. Scripts with the `after_` attribute are executed after +all files, directories, and symlinks have been updated. Scripts without an +`before_` or `after_` attribute are executed in ASCII order of their target +names with respect to files, directories, and symlinks. + +Scripts will normally run with their working directory set to their equivalent +location in the destination directory. If the equivalent location in the +destination directory either does not exist or is not a directory, then chezmoi +will walk up the script's directory hierarchy and run the script in the first +directory that exists and is a directory. + +!!! example + + A script in `~/.local/share/chezmoi/dir/run_script` will be run with a working + directory of `~/dir`. + +chezmoi sets a number of `CHEZMOI*` environment variables when running scripts, +corresponding to commonly-used template data variables. Extra environment +variables can be set in the `env` or `scriptEnv` configuration variables. + +### Scripts on Windows + + + +The execution of scripts on Windows depends on the script's file extension. +Windows will natively execute scripts with a `.bat`, `.cmd`, `.com`, and `.exe` +extensions. Other extensions require an interpreter, which must be in your +`%PATH%`. + +The default script interpreters are: + +| Extension | Command | Arguments | +| --------- | ------------ | --------- | +| `.nu` | `nu` | *none* | +| `.pl` | `perl` | *none* | +| `.py` | `python3` | *none* | +| `.ps1` | `powershell` | `-NoLogo` | +| `.rb` | `ruby` | *none* | + +Script interpreters can be added or overridden by adding the corresponding +extension (without the leading dot) as a key under the `interpreters` +section of the configuration file. + +!!! note + + The leading `.` is dropped from *extension*, for example to specify the + interpreter for `.pl` files you configure `interpreters.pl` (where `.` + in this case just means "a child of" in the configuration file, however + that is specified in your preferred format). + +!!! example + + To change the Python interpreter to `C:\Python39\python3.exe` and add a + Tcl/Tk interpreter, include the following in your config file: + + ```toml title="~/.config/chezmoi/chezmoi.toml" + [interpreters.py] + command = 'C:\Python39\python3.exe' + [interpreters.tcl] + command = "tclsh" + ``` + + Or if using YAML: + + ```yaml title="~/.config/chezmoi/chezmoi.yaml" + interpreters: + py: + command: "C:\Python39\python3.exe" + tcl: + command: "tclsh" + ``` + + Note that the TOML version can also be written like this, which + resembles the YAML version more and makes it clear that the key + for each file extension should not have a leading `.`: + + ```toml title="~/.config/chezmoi/chezmoi.toml" + [interpreters] + py = { command = 'C:\Python39\python3.exe' } + tcl = { command = "tclsh" } + ``` + +!!! note + + If you intend to use PowerShell Core (`pwsh.exe`) as the `.ps1` + interpreter, include the following in your config file: + + ```toml title="~/.config/chezmoi/chezmoi.toml" + [interpreters.ps1] + command = "pwsh" + args = ["-NoLogo"] + ``` + +If the script in the source state is a template (with a `.tmpl` extension), then +chezmoi will strip the `.tmpl` extension and use the next remaining extension to +determine the interpreter to use. + +## `symlink` mode + +By default, chezmoi will create regular files and directories. Setting `mode = +"symlink"` will make chezmoi behave more like a dotfile manager that uses +symlinks by default, i.e. `chezmoi apply` will make dotfiles symlinks to files +in the source directory if the target is a regular file and is not +encrypted, executable, private, or a template. diff --git a/assets/chezmoi.io/docs/reference/templates/1password-functions/index.md b/assets/chezmoi.io/docs/reference/templates/1password-functions/index.md new file mode 100644 index 00000000000..f33e1ef8349 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/1password-functions/index.md @@ -0,0 +1,52 @@ +# 1Password functions + +The `onepassword*` template functions return structured data from +[1Password](https://1password.com/) using the [1Password +CLI](https://developer.1password.com/docs/cli) (`op`). + +!!! info + + When using the 1Password CLI with biometric authentication, chezmoi derives + values from `op account list` that can resolves into the appropriate + 1Password *account-uuid*. + + As an example, if `op account list --format=json` returns the following + structure: + + ```json + [ + { + "url": "account1.1password.ca", + "email": "my@email.com", + "user_uuid": "some-user-uuid", + "account_uuid": "some-account-uuid" + } + ] + ``` + + The following values can be used in the `account` parameter and the value + `some-account-uuid` will be passed as the `--account` parameter to `op`. + + - `some-account-uuid` + - `some-user-uuid` + - `account1.1password.ca` + - `account1` + - `my@email.com` + - `my` + - `my@account1.1password.ca` + - `my@account1` + + If there are multiple accounts and _any_ value exists more than once, that + value will be removed from the account mapping. That is, if you are signed + into `my@email.com` and `your@email.com` for `account1.1password.ca`, then + `account1.1password.ca` will not be a valid lookup value, but `my@account1`, + `my@account1.1password.ca`, `your@account1`, and + `your@account1.1password.ca` would all be valid lookups. + +!!! warning + + Chezmoi has experimental support for [1Password secrets + automation](../../../user-guide/password-managers/1password.md#secrets-automation) + modes. These modes change how the 1Password CLI works and affect all + functions. Most notably, `account` parameters are not allowed on all + 1Password template functions. diff --git a/assets/chezmoi.io/docs/reference/templates/1password-functions/onepassword.md b/assets/chezmoi.io/docs/reference/templates/1password-functions/onepassword.md new file mode 100644 index 00000000000..ec63f112947 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/1password-functions/onepassword.md @@ -0,0 +1,45 @@ +# `onepassword` *uuid* [*vault* [*account*]] + +`onepassword` returns structured data from [1Password](https://1password.com/) +using the [1Password +CLI](https://support.1password.com/command-line-getting-started/) (`op`). +*uuid* is passed to `op item get $UUID --format json` and the output from `op` +is parsed as JSON. The output from `op` is cached so calling `onepassword` +multiple times with the same *uuid* will only invoke `op` once. If the optional +*vault* is supplied, it will be passed along to the `op item get` call, +which can significantly improve performance. If the optional *account* is +supplied, it will be passed along to the `op item get` call, which will help it +look in the right account, in case you have multiple accounts (e.g., personal +and work accounts). + +If there is no valid session in the environment, by default you will be +interactively prompted to sign in. + +The 1password CLI command can be set with the `onePassword.command` config +variable, and extra arguments can be specified with the `onePassword.args` +config variable. + +!!! example + + ``` + {{ (onepassword "$UUID").fields[1].value }} + {{ (onepassword "$UUID" "$VAULT_UUID").fields[1].value }} + {{ (onepassword "$UUID" "$VAULT_UUID" "$ACCOUNT_NAME").fields[1].value }} + {{ (onepassword "$UUID" "" "$ACCOUNT_NAME").fields[1].value }} + ``` + + A more robust way to get a password field would be something like: + + ``` + {{ range (onepassword "$UUID").fields -}} + {{ if and (eq .label "password") (eq .purpose "PASSWORD") -}} + {{ .value -}} + {{ end -}} + {{ end }} + ``` + +!!! warning + + When using [1Password secrets + automation](../../../user-guide/password-managers/1password.md#secrets-automation), + the *account* parameter is not allowed. diff --git a/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordDetailsFields.md b/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordDetailsFields.md new file mode 100644 index 00000000000..eae8b346482 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordDetailsFields.md @@ -0,0 +1,79 @@ +# `onepasswordDetailsFields` *uuid* [*vault* [*account*]] + +`onepasswordDetailsFields` returns structured data from +[1Password](https://1password.com/) using the [1Password +CLI](https://support.1password.com/command-line-getting-started/) (`op`). *uuid* +is passed to `op get item $UUID`, the output from `op` is parsed as JSON, and +elements of `details.fields` are returned as a map indexed by each field's `id` +(if set) or `label` (if set and `id` is not present). + +If there is no valid session in the environment, by default you will be +interactively prompted to sign in. + +The output from `op` is cached so calling `onepasswordDetailsFields` multiple +times with the same *uuid* will only invoke `op` once. If the optional +*vault* is supplied, it will be passed along to the `op get` call, which +can significantly improve performance. If the optional *account* is +supplied, it will be passed along to the `op get` call, which will help it look +in the right account, in case you have multiple accounts (e.g. personal and work +accounts). + +!!! example + + ``` + {{ (onepasswordDetailsFields "$UUID").password.value }} + {{ (onepasswordDetailsFields "$UUID" "$VAULT_UUID").password.value }} + {{ (onepasswordDetailsFields "$UUID" "$VAULT_UUID" "$ACCOUNT_NAME").password.value }} + {{ (onepasswordDetailsFields "$UUID" "" "$ACCOUNT_NAME").password.value }} + ``` + +!!! example + + Given the output from `op`: + + ```json + { + "uuid": "$UUID", + "details": { + "fields": [ + { + "designation": "username", + "name": "username", + "type": "T", + "value": "exampleuser" + }, + { + "designation": "password", + "name": "password", + "type": "P", + "value": "examplepassword" + } + ] + } + } + ``` + + the return value of `onepasswordDetailsFields` will be the map: + + ```json + { + "username": { + "designation": "username", + "name": "username", + "type": "T", + "value": "exampleuser" + }, + "password": { + "designation": "password", + "name": "password", + "type": "P", + "value": "examplepassword" + } + } + ``` + +!!! warning + + When using [1Password secrets + automation](../../../user-guide/password-managers/1password.md#secrets-automation), + the *account* parameter is not allowed. diff --git a/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordDocument.md b/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordDocument.md new file mode 100644 index 00000000000..56ba975b754 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordDocument.md @@ -0,0 +1,34 @@ +# `onepasswordDocument` *uuid* [*vault* [*account*]] + +`onepasswordDocument` returns a document from +[1Password](https://1password.com/) using the [1Password +CLI](https://developer.1password.com/docs/cli) (`op`). *uuid* is passed to `op +get document $UUID` and the output from `op` is returned. The output from `op` +is cached so calling `onepasswordDocument` multiple times with the same *uuid* +will only invoke `op` once. If the optional *vault* is supplied, it will be +passed along to the `op get` call, which can significantly improve performance. +If the optional *account* is supplied, it will be passed along to the `op +get` call, which will help it look in the right account, in case you have +multiple accounts (e.g., personal and work accounts). + +If there is no valid session in the environment, by default you will be +interactively prompted to sign in. + +!!! example + + ``` + {{- onepasswordDocument "$UUID" -}} + {{- onepasswordDocument "$UUID" "$VAULT_UUID" -}} + {{- onepasswordDocument "$UUID" "$VAULT_UUID" "$ACCOUNT_NAME" -}} + {{- onepasswordDocument "$UUID" "" "$ACCOUNT_NAME" -}} + ``` + +!!! warning + + When using [1Password Connect](../../../user-guide/password-managers/1password.md#1password-connect), `onepasswordDocument` is not available. + +!!! warning + + When using [1Password Service + Accounts](../../../user-guide/password-managers/1password.md#1password-service-accounts), + the *account* parameter is not allowed. diff --git a/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordItemFields.md b/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordItemFields.md new file mode 100644 index 00000000000..42c0a25f6c1 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordItemFields.md @@ -0,0 +1,79 @@ +# `onepasswordItemFields` *uuid* [*vault* [*account*]] + +`onepasswordItemFields` returns structured data from +[1Password](https://1password.com/) using the [1Password +CLI](https://support.1password.com/command-line-getting-started/) (`op`). *uuid* +is passed to `op item get $UUID --format json`, the output from `op` is parsed +as JSON, and each element of `details.sections` are iterated over and any +`fields` are returned as a map indexed by each field's `n`. + +If there is no valid session in the environment, by default you will be +interactively prompted to sign in. + +!!! example + + The result of + + ``` + {{ (onepasswordItemFields "abcdefghijklmnopqrstuvwxyz").exampleLabel.value }} + ``` + + is equivalent to calling + + ```console + $ op item get abcdefghijklmnopqrstuvwxyz --fields label=exampleLabel + # or + $ op item get abcdefghijklmnopqrstuvwxyz --fields exampleLabel + ``` + +!!! example + + Given the output from `op`: + + ```json + { + "id": "$UUID", + "title": "$TITLE", + "version": 1, + "vault": { + "id": "$vaultUUID" + }, + "category": "LOGIN", + "last_edited_by": "userUUID", + "created_at": "2022-01-12T16:29:26Z", + "updated_at": "2022-01-12T16:29:26Z", + "sections": [ + { + "id": "$sectionID", + "label": "Related Items" + } + ], + "fields": [ + { + "id": "nerlnqbfzdm5q5g6ydsgdqgdw4", + "type": "STRING", + "label": "exampleLabel", + "value": "exampleValue" + } + ], + } + ``` + + the return value of `onepasswordItemFields` will be the map: + + ```json + { + "exampleLabel": { + "id": "string", + "type": "D4328E0846D2461E8E455D7A07B93397", + "label": "exampleLabel", + "value": "exampleValue" + } + } + ``` + +!!! warning + + When using [1Password secrets + automation](../../../user-guide/password-managers/1password.md#secrets-automation), + the *account* parameter is not allowed. diff --git a/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordRead.md b/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordRead.md new file mode 100644 index 00000000000..777b9b270f0 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/1password-functions/onepasswordRead.md @@ -0,0 +1,29 @@ +# `onepasswordRead` *url* [*account*] + +`onepasswordRead` returns data from [1Password](https://1password.com/) using +the [1Password CLI](https://developer.1password.com/docs/cli) (`op`). *url* is +passed to the `op read --no-newline` command. If *account* is specified, the +extra arguments `--account $ACCOUNT` are passed to `op`. + +If there is no valid session in the environment, by default you will be +interactively prompted to sign in. + +!!! example + + The result of + + ``` + {{ onepasswordRead "op://vault/item/field" }} + ``` + + is equivalent to calling + + ```console + $ op read --no-newline op://vault/item/field + ``` + +!!! warning + + When using [1Password secrets + automation](../../../user-guide/password-managers/1password.md#secrets-automation), + the *account* parameter is not allowed. diff --git a/assets/chezmoi.io/docs/reference/templates/1password-sdk-functions/index.md b/assets/chezmoi.io/docs/reference/templates/1password-sdk-functions/index.md new file mode 100644 index 00000000000..41f54f76af8 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/1password-sdk-functions/index.md @@ -0,0 +1,15 @@ +# 1Password SDK functions + +!!! warning + + 1Password SDK template functions are experimental and may change. + +The `onepasswordSDK*` template functions return structured data from +[1Password](https://1password.com/) using the [1Password +SDK](https://developer.1password.com/docs/sdks/). + +By default, the 1Password service account token is taken from the +`$OP_SERVICE_ACCOUNT_TOKEN` environment variable. The name of the environment +variable can be set with `onepasswordSDK.tokenEnvVar` configuration variable, or +the token can be set explicitly by setting the `onepasswordSDK.token` +configuration variable. diff --git a/assets/chezmoi.io/docs/reference/templates/1password-sdk-functions/onepasswordSDKItemsGet.md b/assets/chezmoi.io/docs/reference/templates/1password-sdk-functions/onepasswordSDKItemsGet.md new file mode 100644 index 00000000000..baef1a744ad --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/1password-sdk-functions/onepasswordSDKItemsGet.md @@ -0,0 +1,17 @@ +# `onepasswordSDKItemsGet` *vault-id* *item-id* + +!!! warning + + `onepasswordSDKItemsGet` is an experimental function and may change. + +`onepasswordSDKItemsGet` returns an item from [1Password](https://1password.com) +using the [1Password SDK](https://developer.1password.com/docs/sdks/). + +The output of `onepasswordSDKItemsGet` is cached so multiple calls to +`onepasswordSDKItemsGet` with the same *vault-id* and *item-id* will return the same value. + +!!! example + + ``` + {{- onepasswordSDKItemsGet "vault" "item" | toJson -}} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/1password-sdk-functions/onepasswordSDKSecretsResolve.md b/assets/chezmoi.io/docs/reference/templates/1password-sdk-functions/onepasswordSDKSecretsResolve.md new file mode 100644 index 00000000000..a6971357aa4 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/1password-sdk-functions/onepasswordSDKSecretsResolve.md @@ -0,0 +1,17 @@ +# `onepasswordSDKSecretsResolve` *url* + +!!! warning + + `onepasswordSDKSecretsResolve` is an experimental function and may change. + +`onepasswordSDKSecretsResolve` returns a secret from [1Password](https://1password.com) +using the [1Password SDK](https://developer.1password.com/docs/sdks/). + +The output of `onepasswordSDKSecretsResolve` is cached so multiple calls to +`onepasswordSDKSecretsResolve` with the same *url* will return the same value. + +!!! example + + ``` + {{- onepasswordSDKSecretsResolve "op://vault/item/field" -}} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/aws-secrets-manager-functions/awsSecretsManager.md b/assets/chezmoi.io/docs/reference/templates/aws-secrets-manager-functions/awsSecretsManager.md new file mode 100644 index 00000000000..ea3956fabbd --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/aws-secrets-manager-functions/awsSecretsManager.md @@ -0,0 +1,8 @@ +# `awsSecretsManager` *arn* + +`awsSecretsManager` returns structured data retrieved from +[AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). *arn* specifies the `SecretId` passed to +[`GetSecretValue`](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html). This can +either be the full ARN or the +[simpler name](https://docs.aws.amazon.com/secretsmanager/latest/userguide/troubleshoot.html#ARN_secretnamehyphen) +if applicable. diff --git a/assets/chezmoi.io/docs/reference/templates/aws-secrets-manager-functions/awsSecretsManagerRaw.md b/assets/chezmoi.io/docs/reference/templates/aws-secrets-manager-functions/awsSecretsManagerRaw.md new file mode 100644 index 00000000000..8767cce69e4 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/aws-secrets-manager-functions/awsSecretsManagerRaw.md @@ -0,0 +1,9 @@ +# `awsSecretsManagerRaw` *arn* + +`awsSecretsManager` returns the raw string value retrieved from +[AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). *arn* specifies the `SecretId` passed to +[`GetSecretValue`](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html). This can +either be the full ARN or the +[simpler name](https://docs.aws.amazon.com/secretsmanager/latest/userguide/troubleshoot.html#ARN_secretnamehyphen) +if applicable. + diff --git a/assets/chezmoi.io/docs/reference/templates/aws-secrets-manager-functions/index.md b/assets/chezmoi.io/docs/reference/templates/aws-secrets-manager-functions/index.md new file mode 100644 index 00000000000..561b4655872 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/aws-secrets-manager-functions/index.md @@ -0,0 +1,8 @@ +# AWS Secrets Manager functions + +The `awsSecretsManager*` functions return data from [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) +using the [`GetSecretValue`](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html) +API. + +The profile and region are pulled from the standard environment variables and shared config files but can be +overridden by setting `awsSecretsManager.profile` and `awsSecretsManager.region` configuration variables respectively. diff --git a/assets/chezmoi.io/docs/reference/templates/azure-key-vault-functions/azureKeyVault.md b/assets/chezmoi.io/docs/reference/templates/azure-key-vault-functions/azureKeyVault.md new file mode 100644 index 00000000000..cbea02df600 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/azure-key-vault-functions/azureKeyVault.md @@ -0,0 +1,15 @@ +# `azureKeyVault` *secret name* [*vault-name*] + +`azureKeyVault` returns a secret value retrieved from an +[Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/). + +The mandatory `secret name` argument specifies the *name of the secret* to +retrieve. + +The optional `vault name` argument specifies the *name of the vault*, if not set, +the default vault name will be used. + +!!! warning + + The current implementation will always return the latest version of the secret. + Retrieving a specific version of a secret is not supported. diff --git a/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwarden.md b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwarden.md new file mode 100644 index 00000000000..5b69403b911 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwarden.md @@ -0,0 +1,16 @@ +# `bitwarden` [*arg*...] + +`bitwarden` returns structured data retrieved from +[Bitwarden](https://bitwarden.com) using the [Bitwarden +CLI](https://bitwarden.com/help/cli) (`bw`). *arg*s are passed to `bw get` +unchanged and the output from `bw get` is parsed as JSON. + +The output from `bw get` is cached so calling `bitwarden` multiple times with +the same arguments will only invoke `bw` once. + +!!! example + + ``` + username = {{ (bitwarden "item" "$ITEMID").login.username }} + password = {{ (bitwarden "item" "$ITEMID").login.password }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenAttachment.md b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenAttachment.md new file mode 100644 index 00000000000..521f2a8e10f --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenAttachment.md @@ -0,0 +1,16 @@ +# `bitwardenAttachment` *filename* *itemid* + +`bitwardenAttachment` returns a document from +[Bitwarden](https://bitwarden.com/) using the [Bitwarden +CLI](https://bitwarden.com/help/article/cli/) (`bw`). *filename* and *itemid* +are passed to `bw get attachment $FILENAME --itemid $ITEMID` and the output is +returned. + +The output from `bw` is cached so calling `bitwardenAttachment` multiple times +with the same *filename* and *itemid* will only invoke `bw` once. + +!!! example + + ``` + {{- bitwardenAttachment "$FILENAME" "$ITEMID" -}} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenAttachmentByRef.md b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenAttachmentByRef.md new file mode 100644 index 00000000000..f335f2f6a71 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenAttachmentByRef.md @@ -0,0 +1,26 @@ +# `bitwardenAttachmentByRef` *filename* *args* + +`bitwardenAttachmentByRef` returns a document from +[Bitwarden](https://bitwarden.com/) using the [Bitwarden +CLI](https://bitwarden.com/help/article/cli/) (`bw`). This method requires two +calls to `bw` to complete: + +1. First, *args* are passed to `bw get` in order to retrieve the item's + *itemid*. +2. Then, *filename* and *itemid* are passed to `bw get attachment $FILENAME + --itemid $ITEMID` and the output from `bw` is returned. + +The output from `bw` is cached so calling `bitwardenAttachmentByRef` multiple +times with the same *filename* and *itemid* will only invoke `bw` once. + +!!! example + + ``` + {{- bitwardenAttachmentByRef "$FILENAME" "$ARGS" -}} + ``` + +!!! example + + ``` + {{- bitwardenAttachmentByRef "id_rsa" "item" "example.com" -}} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenFields.md b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenFields.md new file mode 100644 index 00000000000..e9b3604529a --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenFields.md @@ -0,0 +1,70 @@ +# `bitwardenFields` [*arg*...] + +`bitwardenFields` returns structured data retrieved from +[Bitwarden](https://bitwarden.com) using the [Bitwarden +CLI](https://bitwarden.com/help/cli) (`bw`). *arg*s are passed to `bw get` +unchanged, the output from `bw get` is parsed as JSON, and the elements of +`fields` are returned as a dict indexed by each field's `name`. + +The output from `bw get` is cached so calling `bitwardenFields` multiple times +with the same arguments will only invoke `bw get` once. + +!!! example + + ``` + {{ (bitwardenFields "item" "$ITEMID").token.value }} + ``` + +!!! example + + Given the output from `bw get`: + + ```json + { + "object": "item", + "id": "bf22e4b4-ae4a-4d1c-8c98-ac620004b628", + "organizationId": null, + "folderId": null, + "type": 1, + "name": "example.com", + "notes": null, + "favorite": false, + "fields": [ + { + "name": "hidden", + "value": "hidden-value", + "type": 1 + }, + { + "name": "token", + "value": "token-value", + "type": 0 + } + ], + "login": { + "username": "username-value", + "password": "password-value", + "totp": null, + "passwordRevisionDate": null + }, + "collectionIds": [], + "revisionDate": "2020-10-28T00:21:02.690Z" + } + ``` + + the return value if `bitwardenFields` will be the map: + + ```json + { + "hidden": { + "name": "hidden", + "type": 1, + "value": "hidden-value" + }, + "token": { + "name": "token", + "type": 0, + "value": "token-value" + } + } + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenSecrets.md b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenSecrets.md new file mode 100644 index 00000000000..da368b34dab --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/bitwardenSecrets.md @@ -0,0 +1,20 @@ +# `bitwardenSecrets` *secret-id* [*access-token*] + +`bitwardenSecrets` returns structured data from +[Bitwarden](https://bitwarden.com) using the [Bitwarden Secrets +CLI](https://bitwarden.com/help/secrets-manager-cli/) (`bws`). *secret-id* is +passed to `bws secret get` and the output from `bws secret get` is parsed as +JSON and returned. + +If the additional *access-token* argument is given, it is passed to `bws secret +get` with the `--access-token` flag. + +The output from `bws secret get` is cached so calling `bitwardenSecrets` +multiple times with the same *secret-id* and *access-token* will only invoke +`bws secret get ` once. + +!!! + + ``` + {{ (bitwardenSecrets "be8e0ad8-d545-4017-a55a-b02f014d4158").value }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/index.md b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/index.md new file mode 100644 index 00000000000..3ba5f0b324a --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/index.md @@ -0,0 +1,7 @@ +# Bitwarden functions + +The `bitwarden*` and `rbw*` functions return data from +[Bitwarden](https://bitwarden.com) using the [Bitwarden +CLI](https://bitwarden.com/help/article/cli/) (`bw`), [Bitwarden Secrets +CLI](https://bitwarden.com/help/secrets-manager-cli/) (`bws`), and +[`rbw`](https://github.com/doy/rbw) commands. diff --git a/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/rbw.md b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/rbw.md new file mode 100644 index 00000000000..b7b9dd52aae --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/rbw.md @@ -0,0 +1,15 @@ +# `rbw` *name* [*arg*...] + +`rbw` returns structured data retrieved from [Bitwarden](https://bitwarden.com) +using [`rbw`](https://github.com/doy/rbw). *name* is passed to `rbw get --raw`, +along with any extra *arg*s, and the output is parsed as JSON. + +The output from `rbw get --raw` is cached so calling `rbw` multiple times with +the same arguments will only invoke `rbw` once. + +!!! example + + ``` + username = {{ (rbw "test-entry").data.username }} + password = {{ (rbw "test-entry" "--folder" "my-folder").data.password }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/rbwFields.md b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/rbwFields.md new file mode 100644 index 00000000000..04658600329 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/bitwarden-functions/rbwFields.md @@ -0,0 +1,16 @@ +# `rbwFields` *name* [*arg*...] + +`rbw` returns structured data retrieved from [Bitwarden](https://bitwarden.com) +using [`rbw`](https://github.com/doy/rbw). *name* is passed to `rbw get --raw`, +along with any extra *arg*s, the output is parsed as JSON, and the elements +of `fields` are returned as a dict indexed by each field's `name`. + +The output from `rbw get --raw` is cached so calling `rbwFields` multiple times with +the same arguments will only invoke `rbwFields` once. + +!!! example + + ``` + {{ (rbwFields "item").name.value }} + {{ (rbwFields "item" "--folder" "my-folder").name.value }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/dashlane-functions/dashlaneNote.md b/assets/chezmoi.io/docs/reference/templates/dashlane-functions/dashlaneNote.md new file mode 100644 index 00000000000..2aab50ada89 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/dashlane-functions/dashlaneNote.md @@ -0,0 +1,15 @@ +# `dashlaneNote` *filter* + +`dashlaneNote` returns the content of a secure note from [Dashlane](https://dashlane.com) +using the [Dashlane CLI](https://github.com/Dashlane/dashlane-cli) (`dcli`). +*filter* is passed to `dcli note`, and the output from `dcli +note` is just read as a multiline string. + +The output from `dcli note` is cached so calling `dashlaneNote` multiple +times with the same *filter* will only invoke `dcli note` once. + +!!! example + + ``` + {{ dashlaneNote "filter" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/dashlane-functions/dashlanePassword.md b/assets/chezmoi.io/docs/reference/templates/dashlane-functions/dashlanePassword.md new file mode 100644 index 00000000000..94a8503ac39 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/dashlane-functions/dashlanePassword.md @@ -0,0 +1,15 @@ +# `dashlanePassword` *filter* + +`dashlanePassword` returns structured data from [Dashlane](https://dashlane.com) +using the [Dashlane CLI](https://github.com/Dashlane/dashlane-cli) (`dcli`). +*filter* is passed to `dcli password --output json`, and the output from `dcli +password` is parsed as JSON. + +The output from `dcli password` cached so calling `dashlanePassword` multiple +times with the same *filter* will only invoke `dcli password` once. + +!!! example + + ``` + {{ (index (dashlanePassword "filter") 0).password }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/dashlane-functions/index.md b/assets/chezmoi.io/docs/reference/templates/dashlane-functions/index.md new file mode 100644 index 00000000000..131b38b2147 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/dashlane-functions/index.md @@ -0,0 +1,4 @@ +# Dashlane functions + +The `dashlane*` functions return data from [Dashlane](https://dashlane.com) +using the [Dashlane CLI](https://github.com/Dashlane/dashlane-cli). diff --git a/assets/chezmoi.io/docs/reference/templates/directives.md b/assets/chezmoi.io/docs/reference/templates/directives.md new file mode 100644 index 00000000000..75531dc8677 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/directives.md @@ -0,0 +1,71 @@ +# Directives + +File-specific template options can be set using template directives in the +template of the form: + + chezmoi:template:$KEY=$VALUE + +which sets the template option `$KEY` to `$VALUE`. `$VALUE` must be quoted if it +contains spaces or double quotes. Multiple key/value pairs may be specified on a +single line. + +Lines containing template directives are removed to avoid parse errors from any +delimiters. If multiple directives are present in a file, later directives +override earlier ones. + +## Delimiters + +By default, chezmoi uses the standard `text/template` delimiters `{{` and `}}`. +If a template contains the string: + + chezmoi:template:left-delimiter=$LEFT right-delimiter=$RIGHT + +Then the delimiters `$LEFT` and `$RIGHT` are used instead. Either or both of +`left-delimiter=$LEFT` and `right-delimiter=$RIGHT` may be omitted. If either +`$LEFT` or `$RIGHT` is empty then the default delimiter (`{{` and `}}` +respectively) is set instead. + +The delimiters are specific to the file in which they appear and are not +inherited by templates called from the file. + +!!! example + + ```sh + #!/bin/sh + # chezmoi:template:left-delimiter="# [[" right-delimiter=]] + + # [[ "true" ]] + ``` + +## Line endings + +Many of the template functions available in chezmoi primarily use UNIX-style +line endings (`lf`/`\n`), which may result in unexpected output when running +`chezmoi diff` on a `modify_` template. These line endings can be overridden +with a template directive: + + chezmoi:template:line-endings=$VALUE + +`$VALUE` can be an arbitrary string or one of: + +| Value | Effect | +| -------- | -------------------------------------------------------------------- | +| `crlf` | Use Windows line endings (`\r\n`) | +| `lf` | Use UNIX-style line endings (`\n`) | +| `native` | Use platform-native line endings (`crlf` on Windows, `lf` elsewhere) | + +## Missing keys + +By default, chezmoi will return an error if a template indexes a map with a key +that is not present in the map. This behavior can be changed globally with the +`template.options` configuration variable or with a template directive: + + chezmoi:template:missing-key=$VALUE + +`$VALUE` can be one of: + +| Value | Effect | +| --------- | --------------------------------------------------------------------------------------------- | +| `error` | Return an error on any missing key (default) | +| `invalid` | Ignore missing keys. If printed, the result of the index operation is the string `` | +| `zero` | Ignore missing keys. If printed, the result of the index operation is the zero value | diff --git a/assets/chezmoi.io/docs/reference/templates/doppler-functions/doppler.md b/assets/chezmoi.io/docs/reference/templates/doppler-functions/doppler.md new file mode 100644 index 00000000000..671bbb5a6f5 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/doppler-functions/doppler.md @@ -0,0 +1,15 @@ +# `doppler` *key* [*project* [*config*]] + +`doppler` returns the secret for the specified project and configuration +from [Doppler](https://www.doppler.com) using `doppler secrets download --json --no-file`. + +If either of *project* or *config* are empty or +omitted, then chezmoi will use the value from the +`doppler.project` and +`doppler.config` config variables if they are set and not empty. + +!!! example + + ``` + {{ doppler "SECRET_NAME" "project_name" "configuration_name" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/doppler-functions/dopplerProjectJson.md b/assets/chezmoi.io/docs/reference/templates/doppler-functions/dopplerProjectJson.md new file mode 100644 index 00000000000..35d0a03dd2d --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/doppler-functions/dopplerProjectJson.md @@ -0,0 +1,16 @@ +# `dopplerProjectJson` [*project* [*config*]] + +`dopplerProjectJson` returns the secret for the specified project and configuration +from [Doppler](https://www.doppler.com) using `doppler secrets download --json --no-file` +as `json` structured data. + +If either of *project* or *config* are empty or +omitted, then chezmoi will use the value from the +`doppler.project` and +`doppler.config` config variables if they are set and not empty. + +!!! example + + ``` + {{ (dopplerProjectJson "project_name" "configuration_name").SECRET_NAME }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/doppler-functions/index.md b/assets/chezmoi.io/docs/reference/templates/doppler-functions/index.md new file mode 100644 index 00000000000..825c8eac680 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/doppler-functions/index.md @@ -0,0 +1,5 @@ +# Doppler + +chezmoi includes support for [Doppler](https://www.doppler.com) using the `doppler` +CLI to expose data through the `doppler` and `dopplerProjectJson` +template functions. diff --git a/assets/chezmoi.io/docs/reference/templates/ejson-functions/ejsonDecrypt.md b/assets/chezmoi.io/docs/reference/templates/ejson-functions/ejsonDecrypt.md new file mode 100644 index 00000000000..d884e2be8e4 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/ejson-functions/ejsonDecrypt.md @@ -0,0 +1,16 @@ +# `ejsonDecrypt` *filePath* + +`ejsonDecrypt` returns the decrypted content of an +[ejson](https://github.com/Shopify/ejson)-encrypted file. + +*filePath* indicates where the encrypted file is located. + +The decrypted file is cached so calling `ejsonDecrypt` multiple +times with the same *filePath* will only run through the decryption +process once. The cache is shared with `ejsonDecryptWithKey`. + +!!! example + + ``` + {{ (ejsonDecrypt "my-secrets.ejson").password }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/ejson-functions/ejsonDecryptWithKey.md b/assets/chezmoi.io/docs/reference/templates/ejson-functions/ejsonDecryptWithKey.md new file mode 100644 index 00000000000..78fe501f44b --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/ejson-functions/ejsonDecryptWithKey.md @@ -0,0 +1,17 @@ +# `ejsonDecryptWithKey` *filePath* *key* + +`ejsonDecryptWithKey` returns the decrypted content of an +[ejson](https://github.com/Shopify/ejson)-encrypted file. + +*filePath* indicates where the encrypted file is located, +and *key* is used to decrypt the file. + +The decrypted file is cached so calling `ejsonDecryptWithKey` multiple +times with the same *filePath* will only run through the decryption +process once. The cache is shared with `ejsonDecrypt`. + +!!! example + + ``` + {{ (ejsonDecryptWithKey "my-secrets.ejson" "top-secret-key").password }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/ejson-functions/index.md b/assets/chezmoi.io/docs/reference/templates/ejson-functions/index.md new file mode 100644 index 00000000000..7dcd02d2b21 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/ejson-functions/index.md @@ -0,0 +1,4 @@ +# ejson functions + +The `ejson*` functions return data from +[ejson](https://github.com/Shopify/ejson)-encrypted files. diff --git a/assets/chezmoi.io/docs/reference/templates/functions/comment.md b/assets/chezmoi.io/docs/reference/templates/functions/comment.md new file mode 100644 index 00000000000..2f06e6a4c4c --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/comment.md @@ -0,0 +1,9 @@ +# `comment` *prefix* *text* + +`comment` returns *text* with each line prefixed with *prefix*. + +!!! example + + ``` + {{ "Header" | comment "# " }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/completion.md b/assets/chezmoi.io/docs/reference/templates/functions/completion.md new file mode 100644 index 00000000000..89f97d0caa4 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/completion.md @@ -0,0 +1,10 @@ +# `completion` *shell* + +`completion` returns chezmoi's shell completion for *shell*. *shell* can be one +of `bash`, `fish`, `powershell`, or `zsh`. + +!!! example + + ``` + {{ completion "zsh" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/decrypt.md b/assets/chezmoi.io/docs/reference/templates/functions/decrypt.md new file mode 100644 index 00000000000..99788348baf --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/decrypt.md @@ -0,0 +1,9 @@ +# `decrypt` *ciphertext* + +`decrypt` decrypts *ciphertext* using chezmoi's configured encryption method. + +!!! example + + ``` + {{ joinPath .chezmoi.sourceDir ".ignored-encrypted-file.age" | include | decrypt }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/deleteValueAtPath.md b/assets/chezmoi.io/docs/reference/templates/functions/deleteValueAtPath.md new file mode 100644 index 00000000000..bc38a1309a5 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/deleteValueAtPath.md @@ -0,0 +1,16 @@ +# `deleteValueAtPath` *path* *dict* + +`deleteValueAtPath` modifies *dict* to delete the value at *path* and returns +*dict*. *path* can be either a string containing a `.`-separated list of keys or +a list of keys. + +If *path* does not exist in *dict* then `deleteValueAtPath` returns *dict* +unchanged. + +!!! example + + ``` + {{ dict "outer" (dict "inner" "value") | deleteValueAtPath "outer.inner" | toJson }} + {{ dict | setValueAtPath "key1" "value1" | setValueAtPath "key2.nestedKey" "value2" | toJson }} + {{ dict | setValueAtPath (list "key2" "nestedKey") "value2" | toJson }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/encrypt.md b/assets/chezmoi.io/docs/reference/templates/functions/encrypt.md new file mode 100644 index 00000000000..23a0e0a3324 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/encrypt.md @@ -0,0 +1,3 @@ +# `encrypt` *plaintext* + +`encrypt` encrypts *plaintext* using chezmoi's configured encryption method. diff --git a/assets/chezmoi.io/docs/reference/templates/functions/eqFold.md b/assets/chezmoi.io/docs/reference/templates/functions/eqFold.md new file mode 100644 index 00000000000..04949d027e0 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/eqFold.md @@ -0,0 +1,15 @@ +# `eqFold` *string1* *string2* [*extraString*...] + +`eqFold` returns the boolean truth of comparing *string1* with *string2* and +any number of *extraString*s under Unicode case-folding. + +!!! example + + ``` + {{ $commandOutput := output "path/to/output-FOO.sh" }} + {{ if eqFold "foo" $commandOutput }} + # $commandOutput is "foo"/"Foo"/"FOO"... + {{ else if eqFold "bar" $commandOutput }} + # $commandOutput is "bar"/"Bar"/"BAR"... + {{ end }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/findExecutable.md b/assets/chezmoi.io/docs/reference/templates/functions/findExecutable.md new file mode 100644 index 00000000000..f3e4cf2e408 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/findExecutable.md @@ -0,0 +1,28 @@ +# `findExecutable` *file* *path-list* + +`findExecutable` searches for an executable named *file* in directories +identified by *path-list*. The result will be the executable file concatenated +with the matching path. If an executable *file* cannot be found in *path-list*, +`findExecutable` returns an empty string. + +`findExecutable` is provided as an alternative to [`lookPath`](lookPath.md) so +that you can interrogate the system PATH as it would be configured after +`chezmoi apply`. Like `lookPath`, `findExecutable` is not hermetic: its return +value depends on the state of the filesystem at the moment the template is +executed. Exercise caution when using it in your templates. + +The return value of the first successful call to `findExecutable` is cached, and +future calls to `findExecutable` with the same parameters will return this path. + +!!! info + + On Windows, the resulting path will contain the first found executable + extension as identified by the environment variable `%PathExt%`. + +!!! example + + ``` + {{ if findExecutable "rtx" (list "bin" "go/bin" ".cargo/bin" ".local/bin") }} + # $HOME/.cargo/bin/rtx exists and will probably be in $PATH after apply + {{ end }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/findOneExecutable.md b/assets/chezmoi.io/docs/reference/templates/functions/findOneExecutable.md new file mode 100644 index 00000000000..7501e1649d8 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/findOneExecutable.md @@ -0,0 +1,31 @@ +# `findOneExecutable` *file-list* *path-list* + +`findOneExecutable` searches for an executable from *file-list* in directories +identified by *path-list*, finding the first matching executable in the first +matching directory (each directory is searched for matching executables in +turn). The result will be the executable file concatenated with the matching +path. If an executable from *file-list* cannot be found in *path-list*, +`findOneExecutable` returns an empty string. + +`findOneExecutable` is provided as an alternative to [`lookPath`](lookPath.md) +so that you can interrogate the system PATH as it would be configured after +`chezmoi apply`. Like `lookPath`, `findOneExecutable` is not hermetic: its +return value depends on the state of the filesystem at the moment the template +is executed. Exercise caution when using it in your templates. + +The return value of the first successful call to `findOneExecutable` is cached, +and future calls to `findOneExecutable` with the same parameters will return +this path. + +!!! info + + On Windows, the resulting path will contain the first found executable + extension as identified by the environment variable `%PathExt%`. + +!!! example + + ``` + {{ if findOneExecutable (list "eza" "exa") (list "bin" "go/bin" ".cargo/bin" ".local/bin") }} + # $HOME/.cargo/bin/exa exists and will probably be in $PATH after apply + {{ end }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/fromIni.md b/assets/chezmoi.io/docs/reference/templates/functions/fromIni.md new file mode 100644 index 00000000000..46bc0724d6f --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/fromIni.md @@ -0,0 +1,9 @@ +# `fromIni` *initext* + +`fromIni` returns the parsed value of *initext*. + +!!! example + + ``` + {{ (fromIni "[section]\nkey = value").section.key }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/fromJson.md b/assets/chezmoi.io/docs/reference/templates/functions/fromJson.md new file mode 100644 index 00000000000..aab45301efb --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/fromJson.md @@ -0,0 +1,9 @@ +# `fromJson` *jsontext* + +`fromJson` parses *jsontext* as JSON and returns the parsed value. + +JSON numbers that can be represented exactly as 64-bit signed integers are +returned as such. Otherwise, if the number is in the range of 64-bit IEEE +floating point values, it is returned as such. Otherwise, the number is returned +as a string. See [RFC7159 Section +6](https://www.rfc-editor.org/rfc/rfc7159#section-6). diff --git a/assets/chezmoi.io/docs/reference/templates/functions/fromJsonc.md b/assets/chezmoi.io/docs/reference/templates/functions/fromJsonc.md new file mode 100644 index 00000000000..eff5aafa907 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/fromJsonc.md @@ -0,0 +1,5 @@ +# `fromJsonc` *jsonctext* + +`fromJsonc` parses *jsonctext* as JSONC using +[`github.com/tailscale/hujson`](https://github.com/tailscale/hujson) and returns +the parsed value. diff --git a/assets/chezmoi.io/docs/reference/templates/functions/fromToml.md b/assets/chezmoi.io/docs/reference/templates/functions/fromToml.md new file mode 100644 index 00000000000..20f7154391b --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/fromToml.md @@ -0,0 +1,9 @@ +# `fromToml` *tomltext* + +`fromToml` returns the parsed value of *tomltext*. + +!!! example + + ``` + {{ (fromToml "[section]\nkey = \"value\"").section.key }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/fromYaml.md b/assets/chezmoi.io/docs/reference/templates/functions/fromYaml.md new file mode 100644 index 00000000000..f8ebd9a4002 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/fromYaml.md @@ -0,0 +1,9 @@ +# `fromYaml` *yamltext* + +`fromYaml` returns the parsed value of *yamltext*. + +!!! example + + ``` + {{ (fromYaml "key1: value\nkey2: value").key2 }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/glob.md b/assets/chezmoi.io/docs/reference/templates/functions/glob.md new file mode 100644 index 00000000000..df2665998e5 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/glob.md @@ -0,0 +1,5 @@ +# `glob` *pattern* + +`glob` returns the list of files matching *pattern* according to +[`doublestar.Glob`](https://pkg.go.dev/github.com/bmatcuk/doublestar/v4#Glob). +Relative paths are interpreted relative to the destination directory. diff --git a/assets/chezmoi.io/docs/reference/templates/functions/hexDecode.md b/assets/chezmoi.io/docs/reference/templates/functions/hexDecode.md new file mode 100644 index 00000000000..24ce86c964f --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/hexDecode.md @@ -0,0 +1,9 @@ +# `hexDecode` *hextext* + +`hexDecode` returns *hextext* decoded from a hex-encoding string. + +!!! example + + ``` + {{ hexDecode "68656c6c6f" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/hexEncode.md b/assets/chezmoi.io/docs/reference/templates/functions/hexEncode.md new file mode 100644 index 00000000000..b9a922522d2 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/hexEncode.md @@ -0,0 +1,9 @@ +# `hexEncode` *string* + +`hexEncode` returns *string* encoded as a hex string. + +!!! example + + ``` + {{ hexEncode "example" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/include.md b/assets/chezmoi.io/docs/reference/templates/functions/include.md new file mode 100644 index 00000000000..5d751061056 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/include.md @@ -0,0 +1,4 @@ +# `include` *filename* + +`include` returns the literal contents of the file named `*filename*`. Relative +paths are interpreted relative to the source directory. diff --git a/assets/chezmoi.io/docs/reference/templates/functions/includeTemplate.md b/assets/chezmoi.io/docs/reference/templates/functions/includeTemplate.md new file mode 100644 index 00000000000..79769d8b9c7 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/includeTemplate.md @@ -0,0 +1,6 @@ +# `includeTemplate` *filename* [*data*] + +`includeTemplate` returns the result of executing the contents of *filename* +with the optional *data*. Relative paths are first searched for in +`.chezmoitemplates` and, if not found, are interpreted relative to the source +directory. diff --git a/assets/chezmoi.io/docs/reference/templates/functions/index.md b/assets/chezmoi.io/docs/reference/templates/functions/index.md new file mode 100644 index 00000000000..b571f59031c --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/index.md @@ -0,0 +1,5 @@ +# Functions + +All standard [`text/template`](https://pkg.go.dev/text/template) and [text +template functions from `sprig`](http://masterminds.github.io/sprig/) are +included. chezmoi provides some additional functions. diff --git a/assets/chezmoi.io/docs/reference/templates/functions/ioreg.md b/assets/chezmoi.io/docs/reference/templates/functions/ioreg.md new file mode 100644 index 00000000000..bafaa98bbe7 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/ioreg.md @@ -0,0 +1,22 @@ +# `ioreg` + +On macOS, `ioreg` returns the structured output of the `ioreg -a -l` command, +which includes detailed information about the I/O Kit registry. + +On non-macOS operating systems, `ioreg` returns `nil`. + +The output from `ioreg` is cached so multiple calls to the `ioreg` function +will only execute the `ioreg -a -l` command once. + +!!! example + + ``` + {{ if eq .chezmoi.os "darwin" }} + {{ $serialNumber := index ioreg "IORegistryEntryChildren" 0 "IOPlatformSerialNumber" }} + {{ end }} + ``` + +!!! warning + + The `ioreg` function can be very slow and should not be used. It will be + removed in a later version of chezmoi. diff --git a/assets/chezmoi.io/docs/reference/templates/functions/isExecutable.md b/assets/chezmoi.io/docs/reference/templates/functions/isExecutable.md new file mode 100644 index 00000000000..45b41b25b14 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/isExecutable.md @@ -0,0 +1,11 @@ +# `isExecutable` *file* + +`isExecutable` returns true if a file is executable. + +!!! example + + ``` + {{ if isExecutable "/bin/echo" }} + # echo is executable + {{ end }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/joinPath.md b/assets/chezmoi.io/docs/reference/templates/functions/joinPath.md new file mode 100644 index 00000000000..9ab4263abab --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/joinPath.md @@ -0,0 +1,13 @@ +# `joinPath` *element*... + +`joinPath` joins any number of path elements into a single path, separating +them with the OS-specific path separator. Empty elements are ignored. The +result is cleaned. If the argument list is empty or all its elements are empty, +`joinPath` returns an empty string. On Windows, the result will only be a UNC +path if the first non-empty element is a UNC path. + +!!! example + + ``` + {{ joinPath .chezmoi.homeDir ".zshrc" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/jq.md b/assets/chezmoi.io/docs/reference/templates/functions/jq.md new file mode 100644 index 00000000000..96085fcdaca --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/jq.md @@ -0,0 +1,16 @@ +# `jq` *query* *input* + +`jq` runs the [jq](https://stedolan.github.io/jq/) query *query* against *input* +and returns a list of results. + +!!! example + + ``` + {{ dict "key" "value" | jq ".key" | first }} + ``` + +!!! warning + + `jq` uses [`github.com/itchyny/gojq`](https://github.com/itchyny/gojq), + which behaves slightly differently to the `jq` command in some [edge + cases](https://github.com/itchyny/gojq#difference-to-jq). diff --git a/assets/chezmoi.io/docs/reference/templates/functions/lookPath.md b/assets/chezmoi.io/docs/reference/templates/functions/lookPath.md new file mode 100644 index 00000000000..556843ec3a3 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/lookPath.md @@ -0,0 +1,22 @@ +# `lookPath` *file* + +`lookPath` searches for an executable named *file* in the directories named by +the `PATH` environment variable. If file contains a slash, it is tried directly +and the `PATH` is not consulted. The result may be an absolute path or a path +relative to the current directory. If *file* is not found, `lookPath` returns +an empty string. + +`lookPath` is not hermetic: its return value depends on the state of the +environment and the filesystem at the moment the template is executed. Exercise +caution when using it in your templates. + +The return value of the first successful call to `lookPath` is cached, and +future calls to `lookPath` for the same *file* will return this path. + +!!! example + + ``` + {{ if lookPath "diff-so-fancy" }} + # diff-so-fancy is in $PATH + {{ end }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/lstat.md b/assets/chezmoi.io/docs/reference/templates/functions/lstat.md new file mode 100644 index 00000000000..204509f4ed9 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/lstat.md @@ -0,0 +1,19 @@ +# `lstat` *name* + +`lstat` runs [`os.Lstat`](https://pkg.go.dev/os#File.Lstat) on *name*. If *name* +exists it returns structured data. If *name* does not exist then it returns a +false value. If `os.Lstat` returns any other error then it raises an error. The +structured value returned if *name* exists contains the fields `name`, `size`, +`mode`, `perm`, `modTime`, `isDir`, and `type`. + +`lstat` is not hermetic: its return value depends on the state of the filesystem +at the moment the template is executed. Exercise caution when using it in your +templates. + +!!! example + + ``` + {{ if eq (joinPath .chezmoi.homeDir ".xinitrc" | lstat).type "symlink" }} + # ~/.xinitrc exists and is a symlink + {{ end }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/mozillaInstallHash.md b/assets/chezmoi.io/docs/reference/templates/functions/mozillaInstallHash.md new file mode 100644 index 00000000000..0c5b55425d1 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/mozillaInstallHash.md @@ -0,0 +1,4 @@ +# `mozillaInstallHash` *path* + +`mozillaInstallHash` returns the Mozilla install hash for *path*. This is a +convenience function to assist the management of Firefox profiles. diff --git a/assets/chezmoi.io/docs/reference/templates/functions/output.md b/assets/chezmoi.io/docs/reference/templates/functions/output.md new file mode 100644 index 00000000000..64e76dca347 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/output.md @@ -0,0 +1,13 @@ +# `output` *name* [*arg*...] + +`output` returns the output of executing the command *name* with *arg*s. If +executing the command returns an error then template execution exits with an +error. The execution occurs every time that the template is executed. It is the +user's responsibility to ensure that executing the command is both idempotent +and fast. + +!!! example + + ``` + current-context: {{ output "kubectl" "config" "current-context" | trim }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/pruneEmptyDicts.md b/assets/chezmoi.io/docs/reference/templates/functions/pruneEmptyDicts.md new file mode 100644 index 00000000000..e1ebbbb005a --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/pruneEmptyDicts.md @@ -0,0 +1,11 @@ +# `pruneEmptyDicts` *dict* + +`pruneEmptyDicts` modifies *dict* to remove nested empty dicts. Properties are +pruned from the bottom up, so any nested dicts that themselves only contain +empty dicts are pruned. + +!!! example + + ``` + {{ dict "key" "value" inner (dict) | pruneEmptyDicts | toJson }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/quoteList.md b/assets/chezmoi.io/docs/reference/templates/functions/quoteList.md new file mode 100644 index 00000000000..427f3cbef52 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/quoteList.md @@ -0,0 +1,16 @@ +# `quoteList` *list* + +`quoteList` returns a list where each element is the corresponding element in +*list* quoted. + +!!! example + + ``` + {{ $args := list "alpha" "beta" "gamma" }} + command {{ $args | quoteList }} + ``` + + ``` + [section] + array = [{{- $list | quoteList | join ", " -}}] + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/replaceAllRegex.md b/assets/chezmoi.io/docs/reference/templates/functions/replaceAllRegex.md new file mode 100644 index 00000000000..f7f4be8d30e --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/replaceAllRegex.md @@ -0,0 +1,12 @@ +# `replaceAllRegex` *expr* *repl* *text* + +`replaceAllRegex` returns *text* with all substrings matching the regular +expression *expr* replaced with *repl*. It is an alternative to [sprig's +`regexpReplaceAll` function](http://masterminds.github.io/sprig/strings.html) +with a different argument order that supports pipelining. + +!!! example + + ``` + {{ "foo subject string" | replaceAllRegex "foo" "bar" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/setValueAtPath.md b/assets/chezmoi.io/docs/reference/templates/functions/setValueAtPath.md new file mode 100644 index 00000000000..49a25c4abf0 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/setValueAtPath.md @@ -0,0 +1,17 @@ +# `setValueAtPath` *path* *value* *dict* + +`setValueAtPath` modifies *dict* to set the value at *path* to *value* and +returns *dict*. *path* can be either a string containing a `.`-separated list of +keys or a list of keys. The function will create new key/value pairs in *dict* +if needed. + +This is an alternative to [sprig's `set` +function](http://masterminds.github.io/sprig/dicts.html) with a different +argument order that supports pipelining. + +!!! example + + ``` + {{ dict | setValueAtPath "key1" "value1" | setValueAtPath "key2.nestedKey" "value2" | toJson }} + {{ dict | setValueAtPath (list "key2" "nestedKey") "value2" | toJson }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/stat.md b/assets/chezmoi.io/docs/reference/templates/functions/stat.md new file mode 100644 index 00000000000..788bdb4b965 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/stat.md @@ -0,0 +1,19 @@ +# `stat` *name* + +`stat` runs [`os.Stat`](https://pkg.go.dev/os#File.Stat) on *name*. If *name* +exists it returns structured data. If *name* does not exist then it returns a +false value. If `os.Stat` returns any other error then it raises an error. The +structured value returned if *name* exists contains the fields `name`, `size`, +`mode`, `perm`, `modTime`, `isDir`, and `type`. + +`stat` is not hermetic: its return value depends on the state of the filesystem +at the moment the template is executed. Exercise caution when using it in your +templates. + +!!! example + + ``` + {{ if stat (joinPath .chezmoi.homeDir ".pyenv") }} + # ~/.pyenv exists + {{ end }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/toIni.md b/assets/chezmoi.io/docs/reference/templates/functions/toIni.md new file mode 100644 index 00000000000..aa186da0b88 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/toIni.md @@ -0,0 +1,14 @@ +# `toIni` *value* + +`toIni` returns the ini representation of *value*, which must be a dict. + +!!! example + + ``` + {{ dict "key" "value" "section" (dict "subkey" "subvalue") | toIni }} + ``` + +!!! warning + + The ini format is not well defined, and the particular variant generated + by `toIni` might not be suitable for you. diff --git a/assets/chezmoi.io/docs/reference/templates/functions/toPrettyJson.md b/assets/chezmoi.io/docs/reference/templates/functions/toPrettyJson.md new file mode 100644 index 00000000000..db221a6a3f2 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/toPrettyJson.md @@ -0,0 +1,11 @@ +# `toPrettyJson` [*indent*] *value* + +`toPrettyJson` returns the JSON representation of *value*. The optional *indent* +specifies how much nested elements are indented relative to their parent. +*indent* defaults to two spaces. + +!!! examples + + ``` + {{ dict "a" (dict "b" "c") | toPrettyJson "\t" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/toToml.md b/assets/chezmoi.io/docs/reference/templates/functions/toToml.md new file mode 100644 index 00000000000..337b4eb3281 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/toToml.md @@ -0,0 +1,9 @@ +# `toToml` *value* + +`toToml` returns the TOML representation of *value*. + +!!! example + + ``` + {{ dict "key" "value" | toToml }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/functions/toYaml.md b/assets/chezmoi.io/docs/reference/templates/functions/toYaml.md new file mode 100644 index 00000000000..0306ac858d3 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/toYaml.md @@ -0,0 +1,9 @@ +# `toYaml` *value* + +`toYaml` returns the YAML representation of *value*. + +!!! example + + ``` + {{ dict "key" "value" | toYaml }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubKeys.md b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubKeys.md new file mode 100644 index 00000000000..bbc857d1275 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubKeys.md @@ -0,0 +1,30 @@ +# `gitHubKeys` *user* + +`gitHubKeys` returns *user*'s public SSH keys from GitHub using the GitHub API. +The returned value is a slice of structs with `.ID` and `.Key` fields. + +!!! warning + + If you use this function to populate your `~/.ssh/authorized_keys` file + then you potentially open SSH access to anyone who is able to modify or add + to your GitHub public SSH keys, possibly including certain GitHub + employees. You should not use this function on publicly-accessible machines + and should always verify that no unwanted keys have been added, for example + by using the `-v` / `--verbose` option when running `chezmoi apply` or + `chezmoi update`. + + Additionally, GitHub automatically [removes keys which haven't been used in + the last + year](https://docs.github.com/en/authentication/troubleshooting-ssh/deleted-or-missing-ssh-keys). + This may cause your keys to be removed from `~/.ssh/authorized_keys` + suddenly, and without any warning or indication of the removal. You should + provide one or more keys in plain text alongside this function to avoid + unknowingly losing remote access to your machine. + +!!! example + + ``` + {{ range gitHubKeys "user" }} + {{- .Key }} + {{ end }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubLatestRelease.md b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubLatestRelease.md new file mode 100644 index 00000000000..9d3a01e051d --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubLatestRelease.md @@ -0,0 +1,15 @@ +# `gitHubLatestRelease` *owner-repo* + +`gitHubLatestRelease` calls the GitHub API to retrieve the latest release about +the given *owner-repo*, returning structured data as defined by the [GitHub Go +API +bindings](https://pkg.go.dev/github.com/google/go-github/v63/github#RepositoryRelease). + +Calls to `gitHubLatestRelease` are cached so calling `gitHubLatestRelease` with +the same *owner-repo* will only result in one call to the GitHub API. + +!!! example + + ``` + {{ (gitHubLatestRelease "docker/compose").TagName }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubLatestReleaseAssetURL.md b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubLatestReleaseAssetURL.md new file mode 100644 index 00000000000..73e9c66e587 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubLatestReleaseAssetURL.md @@ -0,0 +1,20 @@ +# `gitHubLatestReleaseAssetURL` *owner-repo* *pattern* + +`gitHubLatestReleaseAssetURL` calls the GitHub API to retrieve the latest +release about the given *owner-repo*, returning structured data as defined by +the [GitHub Go API +bindings](https://pkg.go.dev/github.com/google/go-github/v63/github#RepositoryRelease). +It then iterates through all the release's assets, returning the first one that +matches *pattern*. *pattern* is a shell pattern as [described in +`path.Match`](https://pkg.go.dev/path#Match). + +Calls to `gitHubLatestReleaseAssetURL` are cached so calling +`gitHubLatestReleaseAssetURL` with the same *owner-repo* will only result in one +call to the GitHub API. + +!!! example + + ``` + {{ gitHubLatestReleaseAssetURL "FiloSottile/age" (printf "age-*-%s-%s.tar.gz" .chezmoi.os .chezmoi.arch) }} + {{ gitHubLatestReleaseAssetURL "twpayne/chezmoi" (printf "chezmoi-%s-%s" .chezmoi.os .chezmoi.arch) }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubLatestTag.md b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubLatestTag.md new file mode 100644 index 00000000000..d4f1454ea07 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubLatestTag.md @@ -0,0 +1,23 @@ +# `gitHubLatestTag` *owner-repo* + +`gitHubLatestTag` calls the GitHub API to retrieve the latest tag for the given +*owner-repo*, returning structured data as defined by the [GitHub Go API +bindings](https://pkg.go.dev/github.com/google/go-github/v63/github#RepositoryTag). + +Calls to `gitHubLatestTag` are cached the same as [`githubTags`](gitHubTags.md), +so calling `gitHubLatestTag` with the same *owner-repo* will only result in one +call to the GitHub API. + +!!! example + + ``` + {{ (gitHubLatestTag "docker/compose").Name }} + ``` + +!!! warning + + The `gitHubLatestTag` returns the first tag returned by the [list + repository tags GitHub API + endpoint](https://docs.github.com/en/rest/repos/repos#list-repository-tags). + Although this seems to be the most recent tag, the GitHub API documentation + does not specify the order of the returned tags. diff --git a/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubReleases.md b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubReleases.md new file mode 100644 index 00000000000..3b6810b59b2 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubReleases.md @@ -0,0 +1,29 @@ +# `gitHubReleases` *owner-repo* + +`gitHubReleases` calls the GitHub API to retrieve the first page of releases for +the given *owner-repo*, returning structured data as defined by the [GitHub Go +API +bindings](https://pkg.go.dev/github.com/google/go-github/v63/github#RepositoryRelease). + +Calls to `gitHubReleases` are cached so calling `gitHubReleases` with the same +*owner-repo* will only result in one call to the GitHub API. + +!!! example + + ``` + {{ (index (gitHubReleases "docker/compose") 0).TagName }} + ``` + +!!! note + + The maximum number of items returned by `gitHubReleases` is determined by + default page size for the GitHub API. + +!!! warning + + The values returned by `gitHubReleases` are not directly queryable via the + [`jq`](../functions/jq.md) function and must instead be converted through JSON: + + ``` + {{ gitHubReleases "docker/compose" | toJson | fromJson | jq ".[0].tag_name" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubTags.md b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubTags.md new file mode 100644 index 00000000000..a1627e8cac1 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/github-functions/gitHubTags.md @@ -0,0 +1,29 @@ +# `gitHubTags` *owner-repo* + +`gitHubTags` calls the GitHub API to retrieve the first page of tags for +the given *owner-repo*, returning structured data as defined by the [GitHub Go +API +bindings](https://pkg.go.dev/github.com/google/go-github/v63/github#RepositoryTag). + +Calls to `gitHubTags` are cached so calling `gitHubTags` with the +same *owner-repo* will only result in one call to the GitHub API. + +!!! example + + ``` + {{ (index (gitHubTags "docker/compose") 0).Name }} + ``` + +!!! note + + The maximum number of items returned by `gitHubReleases` is determined by + default page size for the GitHub API. + +!!! warning + + The values returned by `gitHubTags` are not directly queryable via the + [`jq`](../functions/jq.md) function and must instead be converted through JSON: + + ``` + {{ gitHubTags "docker/compose" | toJson | fromJson | jq ".[0].name" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/github-functions/index.md b/assets/chezmoi.io/docs/reference/templates/github-functions/index.md new file mode 100644 index 00000000000..75d62539514 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/github-functions/index.md @@ -0,0 +1,21 @@ +# GitHub functions + +The `gitHub*` template functions return data from the GitHub API. + +By default, chezmoi makes anonymous GitHub API requests, which are subject to +[GitHub's rate +limits](https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting) +(currently 60 requests per hour per source IP address). chezmoi caches results +from identical GitHub API requests for the period defined in +`gitHub.refreshPeriod` (default one minute). + +If any of the environment variables `$CHEZMOI_GITHUB_ACCESS_TOKEN`, +`$GITHUB_ACCESS_TOKEN`, or `$GITHUB_TOKEN` are found, then the first one found +will be used to authenticate the GitHub API requests which have a higher rate +limit (currently 5,000 requests per hour per user). + +In practice, GitHub API rate limits are high enough chezmoi's caching of results +mean that you should rarely need to set a token, unless you are sharing a source +IP address with many other GitHub users. If needed, the GitHub documentation +describes how to [create a personal access +token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). diff --git a/assets/chezmoi.io/docs/reference/templates/gopass-functions/gopass.md b/assets/chezmoi.io/docs/reference/templates/gopass-functions/gopass.md new file mode 100644 index 00000000000..49ca32347b8 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/gopass-functions/gopass.md @@ -0,0 +1,14 @@ +# `gopass` *gopass-name* + +`gopass` returns passwords stored in [gopass](https://www.gopass.pw/) using the +gopass CLI (`gopass`). *gopass-name* is passed to `gopass show --password +$GOPASS_NAME` and the first line of the output of `gopass` is returned with the +trailing newline stripped. The output from `gopass` is cached so calling +`gopass` multiple times with the same *gopass-name* will only invoke `gopass` +once. + +!!! example + + ``` + {{ gopass "$PASS_NAME" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/gopass-functions/gopassRaw.md b/assets/chezmoi.io/docs/reference/templates/gopass-functions/gopassRaw.md new file mode 100644 index 00000000000..95a4ec50547 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/gopass-functions/gopassRaw.md @@ -0,0 +1,7 @@ +# `gopassRaw` *gopass-name* + +`gopass` returns raw passwords stored in [gopass](https://www.gopass.pw/) using +the gopass CLI (`gopass`). *gopass-name* is passed to `gopass show --noparsing +$GOPASS_NAME` and the output is returned. The output from `gopassRaw` is +cached so calling `gopassRaw` multiple times with the same *gopass-name* will +only invoke `gopass` once. diff --git a/assets/chezmoi.io/docs/reference/templates/gopass-functions/index.md b/assets/chezmoi.io/docs/reference/templates/gopass-functions/index.md new file mode 100644 index 00000000000..3bf811d62fb --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/gopass-functions/index.md @@ -0,0 +1,4 @@ +# gopass functions + +The `gopass*` template functions return data stored in +[gopass](https://www.gopass.pw/) using the gopass CLI (`gopass`). diff --git a/assets/chezmoi.io/docs/reference/templates/hcp-vault-secrets-functions/hcpVaultSecret.md b/assets/chezmoi.io/docs/reference/templates/hcp-vault-secrets-functions/hcpVaultSecret.md new file mode 100644 index 00000000000..0319ad7f002 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/hcp-vault-secrets-functions/hcpVaultSecret.md @@ -0,0 +1,16 @@ +# `hcpVaultSecret` *key* [*application-name* [*project-id* [*organization-id*]]] + +`hcpVaultSecret` returns the plaintext secret from [HCP Vault +Secrets](https://developer.hashicorp.com/hcp/docs/vault-secrets) using `vlt +secrets get --plaintext`. + +If any of *application-name*, *project-id*, or *organization-id* are empty or +omitted, then chezmoi will use the value from the +`hcpVaultSecret.applicationName`, `hcpVaultSecret.projectId`, and +`hcpVaultSecret.organizationId` config variables if they are set and not empty. + +!!! example + + ``` + {{ hcpVaultSecret "username" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/hcp-vault-secrets-functions/hcpVaultSecretJson.md b/assets/chezmoi.io/docs/reference/templates/hcp-vault-secrets-functions/hcpVaultSecretJson.md new file mode 100644 index 00000000000..dcc25dcb26e --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/hcp-vault-secrets-functions/hcpVaultSecretJson.md @@ -0,0 +1,16 @@ +# `hcpVaultSecretJson` *key* [*application-name* [*project-id* [*organization-id*]]] + +`hcpVaultSecretJson` returns structured data from [HCP Vault +Secrets](https://developer.hashicorp.com/hcp/docs/vault-secrets) using `vlt +secrets get --format=json`. + +If any of *application-name*, *project-id*, or *organization-id* are empty or omitted, then +chezmoi will use the value from the `hcpVaultSecret.applicationName`, +`hcpVaultSecret.projectId`, and `hcpVaultSecret.organizationId` config variables +if they are set and not empty. + +!!! example + + ``` + {{ (hcpVaultSecretJson "secret_name" "application_name").created_by.email }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/hcp-vault-secrets-functions/index.md b/assets/chezmoi.io/docs/reference/templates/hcp-vault-secrets-functions/index.md new file mode 100644 index 00000000000..675ae8a21e4 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/hcp-vault-secrets-functions/index.md @@ -0,0 +1,6 @@ +# HCP Vault Secrets + +chezmoi includes support for [HCP Vault +Secrets](https://developer.hashicorp.com/hcp/docs/vault-secrets) using the `vlt` +CLI to expose data through the `hcpVaultSecret` and `hcpVaultSecretJson` +template functions. diff --git a/assets/chezmoi.io/docs/reference/templates/index.md b/assets/chezmoi.io/docs/reference/templates/index.md new file mode 100644 index 00000000000..3b8f87460f2 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/index.md @@ -0,0 +1,35 @@ +# Templates + +chezmoi executes templates using +[`text/template`](https://pkg.go.dev/text/template). The result is treated +differently depending on whether the target is a file or a symlink. + +If target is a file, then: + +* If the result is an empty string, then the file is removed. + +* Otherwise, the target file contents are result. + +If the target is a symlink, then: + +* Leading and trailing whitespace are stripped from the result. + +* If the result is an empty string, then the symlink is removed. + +* Otherwise, the target symlink target is the result. + +chezmoi executes templates using `text/template`'s `missingkey=error` option, +which means that misspelled or missing keys will raise an error. This can be +overridden by setting a list of options in the configuration file. + +!!! hint + + For a full list of template options, see + [`Template.Option`](https://pkg.go.dev/text/template?tab=doc#Template.Option). + +!!! example + + ```toml title="~/.config/chezmoi/chezmoi.toml" + [template] + options = ["missingkey=zero"] + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/exit.md b/assets/chezmoi.io/docs/reference/templates/init-functions/exit.md new file mode 100644 index 00000000000..17fe927a056 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/exit.md @@ -0,0 +1,3 @@ +# `exit` *code* + +`exit` stops template execution and causes chezmoi to exit with *code*. diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/index.md b/assets/chezmoi.io/docs/reference/templates/init-functions/index.md new file mode 100644 index 00000000000..5c8ff9dd562 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/index.md @@ -0,0 +1,5 @@ +# Init functions + +These template functions are only available when generating a config file with +`chezmoi init`. For testing with `chezmoi execute-template`, pass the `--init` +flag to enable them. diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/promptBool.md b/assets/chezmoi.io/docs/reference/templates/init-functions/promptBool.md new file mode 100644 index 00000000000..9a9d101dbb5 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/promptBool.md @@ -0,0 +1,11 @@ +# `promptBool` *prompt* [*default*] + +`promptBool` prompts the user with *prompt* and returns the user's response +interpreted as a boolean. If *default* is passed and the user's response is +empty then it returns *default*. The user's response is interpreted as follows +(case insensitive): + +| Response | Result | +| ----------------------- | ------- | +| 1, on, t, true, y, yes | `true` | +| 0, off, f, false, n, no | `false` | diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/promptBoolOnce.md b/assets/chezmoi.io/docs/reference/templates/init-functions/promptBoolOnce.md new file mode 100644 index 00000000000..1c92f53bc40 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/promptBoolOnce.md @@ -0,0 +1,11 @@ +# `promptBoolOnce` *map* *path* *prompt* [*default*] + +`promptBoolOnce` returns the value of *map* at *path* if it exists and is a +boolean value, otherwise it prompts the user for a boolean value with *prompt* +and an optional *default* using `promptBool`. + +!!! example + + ``` + {{ $hasGUI := promptBoolOnce . "hasGUI" "Does this machine have a GUI" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/promptChoice.md b/assets/chezmoi.io/docs/reference/templates/init-functions/promptChoice.md new file mode 100644 index 00000000000..9df5cdb5ce6 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/promptChoice.md @@ -0,0 +1,12 @@ +# `promptChoice` *prompt* *choices* [*default*] + +`promptChoice` prompts the user with *prompt* and *choices* and returns the user's response. *choices* must be a list of strings. If *default* is passed and the user's response is empty then it returns *default*. + +!!! example + + ``` + {{- $choices := list "desktop" "server" -}} + {{- $hosttype := promptChoice "What type of host are you on" $choices -}} + [data] + hosttype = {{- $hosttype | quote -}} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/promptChoiceOnce.md b/assets/chezmoi.io/docs/reference/templates/init-functions/promptChoiceOnce.md new file mode 100644 index 00000000000..fd5aacde936 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/promptChoiceOnce.md @@ -0,0 +1,14 @@ +# `promptChoiceOnce` *map* *path* *prompt* *choices* [*default*] + +`promptChoiceOnce` returns the value of *map* at *path* if it exists and is a +string, otherwise it prompts the user for one of *choices* with *prompt* and an +optional *default* using `promptChoice`. + +!!! example + + ``` + {{- $choices := list "desktop" "laptop" "server" "termux" -}} + {{- $hosttype := promptChoiceOnce . "hosttype" "What type of host are you on" $choices -}} + [data] + hosttype = {{- $hosttype | quote -}} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/promptInt.md b/assets/chezmoi.io/docs/reference/templates/init-functions/promptInt.md new file mode 100644 index 00000000000..cad1e6b9604 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/promptInt.md @@ -0,0 +1,5 @@ +# `promptInt` *prompt* [*default*] + +`promptInt` prompts the user with *prompt* and returns the user's response +interpreted as an integer. If *default* is passed and the user's response is +empty then it returns *default*. diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/promptIntOnce.md b/assets/chezmoi.io/docs/reference/templates/init-functions/promptIntOnce.md new file mode 100644 index 00000000000..b232c419733 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/promptIntOnce.md @@ -0,0 +1,11 @@ +# `promptIntOnce` *map* *path* *prompt* [*default*] + +`promptIntOnce` returns the value of *map* at *path* if it exists and is an +integer value, otherwise it prompts the user for a integer value with *prompt* +and an optional *default* using `promptInt`. + +!!! example + + ``` + {{ $monitors := promptIntOnce . "monitors" "How many monitors does this machine have" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/promptString.md b/assets/chezmoi.io/docs/reference/templates/init-functions/promptString.md new file mode 100644 index 00000000000..66ceed8d500 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/promptString.md @@ -0,0 +1,13 @@ +# `promptString` *prompt* [*default*] + +`promptString` prompts the user with *prompt* and returns the user's response +with all leading and trailing spaces stripped. If *default* is passed and the +user's response is empty then it returns *default*. + +!!! example + + ``` + {{ $email := promptString "email" -}} + [data] + email = {{ $email | quote }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/promptStringOnce.md b/assets/chezmoi.io/docs/reference/templates/init-functions/promptStringOnce.md new file mode 100644 index 00000000000..2527f14c4b0 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/promptStringOnce.md @@ -0,0 +1,11 @@ +# `promptStringOnce` *map* *path* *prompt* [*default*] + +`promptStringOnce` returns the value of *map* at *path* if it exists and is a +string value, otherwise it prompts the user for a string value with *prompt* and +an optional *default* using `promptString`. + +!!! example + + ``` + {{ $email := promptStringOnce . "email" "What is your email address" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/stdinIsATTY.md b/assets/chezmoi.io/docs/reference/templates/init-functions/stdinIsATTY.md new file mode 100644 index 00000000000..763e6dc27f6 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/stdinIsATTY.md @@ -0,0 +1,16 @@ +# `stdinIsATTY` + +`stdinIsATTY` returns `true` if chezmoi's standard input is a TTY. It is +primarily useful for determining whether `prompt*` functions should be called +or default values be used. + +!!! example + + ``` + {{ $email := "" }} + {{ if stdinIsATTY }} + {{ $email = promptString "email" }} + {{ else }} + {{ $email = "user@example.com" }} + {{ end }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/init-functions/writeToStdout.md b/assets/chezmoi.io/docs/reference/templates/init-functions/writeToStdout.md new file mode 100644 index 00000000000..d16b0d28f45 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/init-functions/writeToStdout.md @@ -0,0 +1,9 @@ +# `writeToStdout` *string*... + +`writeToStdout` writes each *string* to stdout. + +!!! example + + ``` + {{- writeToStdout "Hello, world\n" -}} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/index.md b/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/index.md new file mode 100644 index 00000000000..0abba20ad84 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/index.md @@ -0,0 +1,19 @@ +# KeePassXC functions + +The `keepassxc*` template functions return structured data retrieved from a +[KeePassXC](https://keepassxc.org/) database using the KeePassXC CLI +(`keepassxc-cli`) + +The database is configured by setting `keepassxc.database` in the configuration +file. You will be prompted for the database password the first time +`keepassxc-cli` is run, and the password is cached, in plain text, in memory +until chezmoi terminates. + +The command used can by changed by setting the `keepassxc.command` configuration +variable, and extra arguments can be added by setting `keepassxc.args`. The +password prompt can be disabled by setting `keepassxc.prompt` to `false`. + +By default, chezmoi will prompt for the KeePassXC password when required and +cache it for the duration of chezmoi's execution. Setting `keepassxc.mode` to +`open` will tell chezmoi to instead open KeePassXC's console with `keepassxc-cli +open`. chezmoi will use this console to request values from KeePassXC. diff --git a/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/keepassxc.md b/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/keepassxc.md new file mode 100644 index 00000000000..b798f9b7fa6 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/keepassxc.md @@ -0,0 +1,14 @@ +# `keepassxc` *entry* + +`keepassxc` returns structured data for *entry* using `keepassxc-cli`. + +The output from `keepassxc-cli` is parsed into key-value pairs and cached so +calling `keepassxc` multiple times with the same *entry* will only invoke +`keepassxc-cli` once. + +!!! example + + ``` + username = {{ (keepassxc "example.com").UserName }} + password = {{ (keepassxc "example.com").Password }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/keepassxcAttachment.md b/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/keepassxcAttachment.md new file mode 100644 index 00000000000..cb45d8f83c3 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/keepassxcAttachment.md @@ -0,0 +1,14 @@ +# `keepassxcAttachment` *entry* *name* + +`keepassxcAttachment` returns the attachment with *name* of *entry* using +`keepassxc-cli`. + +!!! info + + `keepassxcAttachment` requires `keepassxc-cli` version 2.7.0 or later. + +!!! example + + ``` + {{- keepassxcAttachment "SSH Config" "config" -}} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/keepassxcAttribute.md b/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/keepassxcAttribute.md new file mode 100644 index 00000000000..1147ed712ae --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/keepassxc-functions/keepassxcAttribute.md @@ -0,0 +1,10 @@ +# `keepassxcAttribute` *entry* *attribute* + +`keepassxcAttribute` returns the attribute *attribute* of *entry* using +`keepassxc-cli`, with any leading or trailing whitespace removed. + +!!! example + + ``` + {{ keepassxcAttribute "SSH Key" "private-key" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/keeper-functions/index.md b/assets/chezmoi.io/docs/reference/templates/keeper-functions/index.md new file mode 100644 index 00000000000..f3f52981f85 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/keeper-functions/index.md @@ -0,0 +1,7 @@ +# Keeper functions + +The `keeper*` functions return data from [Keeper](https://www.keepersecurity.com/) +[Commander CLI](https://docs.keeper.io/secrets-manager/commander-cli) (`keeper`). + +The command used can by changed by setting the `keeper.command` configuration +variable, and extra arguments can be added by setting `keeper.args`. diff --git a/assets/chezmoi.io/docs/reference/templates/keeper-functions/keeper.md b/assets/chezmoi.io/docs/reference/templates/keeper-functions/keeper.md new file mode 100644 index 00000000000..620ec89a418 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/keeper-functions/keeper.md @@ -0,0 +1,6 @@ +# `keeper` *uid* + +`keeper` returns structured data retrieved from +[Keeper](https://www.keepersecurity.com/) using the [Commander +CLI](https://docs.keeper.io/secrets-manager/commander-cli). *uid* is passed to +`keeper get --format=json` and the output is parsed as JSON. diff --git a/assets/chezmoi.io/docs/reference/templates/keeper-functions/keeperDataFields.md b/assets/chezmoi.io/docs/reference/templates/keeper-functions/keeperDataFields.md new file mode 100644 index 00000000000..c40ccc8ed16 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/keeper-functions/keeperDataFields.md @@ -0,0 +1,12 @@ +# `keeperDataFields` *uid* + +`keeperDataFields` returns the `.data.fields` elements of `keeper get +--format=json *uid*` indexed by `type`. + +## Example + +``` +url = {{ (keeperDataFields "$UID").url }} +login = {{ index (keeperDataFields "$UID").login 0 }} +password = {{ index (keeperDataFields "$UID").password 0 }} +``` diff --git a/assets/chezmoi.io/docs/reference/templates/keeper-functions/keeperFindPassword.md b/assets/chezmoi.io/docs/reference/templates/keeper-functions/keeperFindPassword.md new file mode 100644 index 00000000000..210aa334ffa --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/keeper-functions/keeperFindPassword.md @@ -0,0 +1,4 @@ +# `keeperFindPassword` *query* + +`keeperFindPassword` returns the output of `keeper find-password query`. *query* +can be a UID or a path. diff --git a/assets/chezmoi.io/docs/reference/templates/keyring-functions/keyring.md b/assets/chezmoi.io/docs/reference/templates/keyring-functions/keyring.md new file mode 100644 index 00000000000..3e2f8cd617a --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/keyring-functions/keyring.md @@ -0,0 +1,26 @@ +# `keyring` *service* *user* + +`keyring` retrieves the value associated with *service* and *user* from the +user's keyring. + +| OS | Keyring | +| ------- | --------------------------- | +| macOS | Keychain | +| Linux | GNOME Keyring | +| Windows | Windows Credentials Manager | +| FreeBSD | GNOME Keyring | + +!!! example + + ``` + [github] + user = {{ .github.user | quote }} + token = {{ keyring "github" .github.user | quote }} + ``` + +!!! warning + + On FreeBSD, the `keyring` template function is only available if chezmoi + was compiled with cgo enabled. The official release binaries of chezmoi are + **not** compiled with cgo enabled, and `keyring` will always return an + empty string. diff --git a/assets/chezmoi.io/docs/reference/templates/lastpass-functions/index.md b/assets/chezmoi.io/docs/reference/templates/lastpass-functions/index.md new file mode 100644 index 00000000000..d393e0c8ec8 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/lastpass-functions/index.md @@ -0,0 +1,5 @@ +# LastPass functions + +The `lastpass*` template functions return structured data from +[LastPass](https://lastpass.com/) using the [LastPass +CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html) (`lpass`). diff --git a/assets/chezmoi.io/docs/reference/templates/lastpass-functions/lastpass.md b/assets/chezmoi.io/docs/reference/templates/lastpass-functions/lastpass.md new file mode 100644 index 00000000000..f044ac5afa4 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/lastpass-functions/lastpass.md @@ -0,0 +1,17 @@ +# `lastpass` *id* + +`lastpass` returns structured data from [LastPass](https://lastpass.com/) using +the [LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html) +(`lpass`). *id* is passed to `lpass show --json $ID` and the output from +`lpass` is parsed as JSON. In addition, the `note` field, if present, is +further parsed as colon-separated key-value pairs. The structured data is an +array so typically the `index` function is used to extract the first item. The +output from `lastpass` is cached so calling `lastpass` multiple times with the +same *id* will only invoke `lpass` once. + +!!! example + + ``` + githubPassword = {{ (index (lastpass "GitHub") 0).password | quote }} + {{ (index (lastpass "SSH") 0).note.privateKey }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/lastpass-functions/lastpassRaw.md b/assets/chezmoi.io/docs/reference/templates/lastpass-functions/lastpassRaw.md new file mode 100644 index 00000000000..a4f625c81a8 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/lastpass-functions/lastpassRaw.md @@ -0,0 +1,12 @@ +# `lastpassRaw` *id* + +`lastpassRaw` returns structured data from [LastPass](https://lastpass.com/) +using the [LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html) +(`lpass`). It behaves identically to the `lastpass` function, except that no +further parsing is done on the `note` field. + +!!! example + + ``` + {{ (index (lastpassRaw "SSH Private Key") 0).note }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/pass-functions/index.md b/assets/chezmoi.io/docs/reference/templates/pass-functions/index.md new file mode 100644 index 00000000000..32885021912 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/pass-functions/index.md @@ -0,0 +1,16 @@ +# pass functions + +The `pass` template functions return passwords stored in +[pass](https://www.passwordstore.org/) using the pass CLI (`pass`). + +!!! hint + + To use a pass-compatible password manager like + [passage](https://github.com/FiloSottile/passage), set `pass.command` to + the name of the binary and use chezmoi's `pass*` template functions as if + you were using pass. + + ```toml title="~/.config/chezmoi/chezmoi.toml" + [pass] + command = "passage" + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/pass-functions/pass.md b/assets/chezmoi.io/docs/reference/templates/pass-functions/pass.md new file mode 100644 index 00000000000..26548a1caa6 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/pass-functions/pass.md @@ -0,0 +1,13 @@ +# `pass` *pass-name* + +`pass` returns passwords stored in [pass](https://www.passwordstore.org/) using +the pass CLI (`pass`). *pass-name* is passed to `pass show $PASS_NAME` and the +first line of the output of `pass` is returned with the trailing newline +stripped. The output from `pass` is cached so calling `pass` multiple times +with the same *pass-name* will only invoke `pass` once. + +!!! example + + ``` + {{ pass "$PASS_NAME" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/pass-functions/passFields.md b/assets/chezmoi.io/docs/reference/templates/pass-functions/passFields.md new file mode 100644 index 00000000000..4356f553328 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/pass-functions/passFields.md @@ -0,0 +1,31 @@ +# `passFields` *pass-name* + +`passFields` returns structured data stored in +[pass](https://www.passwordstore.org) using the pass CLI (`pass`). *pass-name* +is passed to `pass show $PASS_NAME` and the output is parsed as colon-separated +key-value pairs, one per line. The return value is a map of keys to values. + +!!! example + + Given the output from `pass`: + + ``` + GitHub + login: username + password: secret + ``` + + the return value will be the map: + + ```json + { + "login": "username", + "password": "secret" + } + ``` + +!!! example + + ``` + {{ (passFields "GitHub").password }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/pass-functions/passRaw.md b/assets/chezmoi.io/docs/reference/templates/pass-functions/passRaw.md new file mode 100644 index 00000000000..2c2bd74fb27 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/pass-functions/passRaw.md @@ -0,0 +1,7 @@ +# `passRaw` *pass-name* + +`passRaw` returns passwords stored in [pass](https://www.passwordstore.org/) +using the pass CLI (`pass`). *pass-name* is passed to `pass show $PASS_NAME` +and the output is returned. The output from `pass` is cached so calling +`passRaw` multiple times with the same *pass-name* will only invoke `pass` +once. diff --git a/assets/chezmoi.io/docs/reference/templates/passhole-functions/index.md b/assets/chezmoi.io/docs/reference/templates/passhole-functions/index.md new file mode 100644 index 00000000000..7b9b202da9e --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/passhole-functions/index.md @@ -0,0 +1,4 @@ +# Passhole + +chezmoi includes support for [KeePass](https://keepass.info/) using the +[Passhole CLI](https://github.com/Evidlo/passhole) (`ph`). diff --git a/assets/chezmoi.io/docs/reference/templates/passhole-functions/passhole.md b/assets/chezmoi.io/docs/reference/templates/passhole-functions/passhole.md new file mode 100644 index 00000000000..4a4df535473 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/passhole-functions/passhole.md @@ -0,0 +1,10 @@ +# passhole *path* *field* + +`passhole` returns the *field* of *path* from a [KeePass](https://keepass.info/) +database using [passhole](https://github.com/Evidlo/passhole)'s `ph` command. + +!!! example + + ``` + {{ passhole "example.com" "password" }} + ``` diff --git a/assets/chezmoi.io/docs/reference/templates/secret-functions/index.md b/assets/chezmoi.io/docs/reference/templates/secret-functions/index.md new file mode 100644 index 00000000000..4b3b56059bc --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/secret-functions/index.md @@ -0,0 +1,4 @@ +# Generic secret functions + +The `secret*` template functions return the output of the generic secret command +defined by the `secret.command` configuration variable. diff --git a/assets/chezmoi.io/docs/reference/templates/secret-functions/secret.md b/assets/chezmoi.io/docs/reference/templates/secret-functions/secret.md new file mode 100644 index 00000000000..7e0aef08e77 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/secret-functions/secret.md @@ -0,0 +1,7 @@ +# `secret` [*arg*...] + +`secret` returns the output of the generic secret command defined by the +`secret.command` configuration variable with `secret.args` and *arg*s with +leading and trailing whitespace removed. The output is cached so multiple calls +to `secret` with the same *arg*s will only invoke the generic secret command +once. diff --git a/assets/chezmoi.io/docs/reference/templates/secret-functions/secretJSON.md b/assets/chezmoi.io/docs/reference/templates/secret-functions/secretJSON.md new file mode 100644 index 00000000000..00872b0df75 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/secret-functions/secretJSON.md @@ -0,0 +1,6 @@ +# `secretJSON` [*arg*...] + +`secretJSON` returns structured data from the generic secret command defined by +the `secret.command` configuration variable with `secret.args` and *arg*s. The +output is parsed as JSON. The output is cached so multiple calls to `secret` +with the same *args* will only invoke the generic secret command once. diff --git a/assets/chezmoi.io/docs/reference/templates/variables.md b/assets/chezmoi.io/docs/reference/templates/variables.md new file mode 100644 index 00000000000..26438cba637 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/variables.md @@ -0,0 +1,51 @@ +# Variables + +chezmoi provides the following automatically-populated variables: + +| Variable | Type | Value | +|-------------------------------| -------- |-------------------------------------------------------------------------------------------------------------------------------------------------------| +| `.chezmoi.arch` | string | Architecture, e.g. `amd64`, `arm`, etc. as returned by [runtime.GOARCH](https://pkg.go.dev/runtime?tab=doc#pkg-constants) | +| `.chezmoi.args` | []string | The arguments passed to the `chezmoi` command, starting with the program command | +| `.chezmoi.cacheDir` | string | The cache directory | +| `.chezmoi.config` | object | The configuration, as read from the config file | +| `.chezmoi.configFile` | string | The path to the configuration file used by chezmoi | +| `.chezmoi.executable` | string | The path to the `chezmoi` executable, if available | +| `.chezmoi.fqdnHostname` | string | The fully-qualified domain name hostname of the machine chezmoi is running on | +| `.chezmoi.gid` | string | The primary group ID | +| `.chezmoi.group` | string | The group of the user running chezmoi | +| `.chezmoi.homeDir` | string | The home directory of the user running chezmoi | +| `.chezmoi.hostname` | string | The hostname of the machine chezmoi is running on, up to the first `.` | +| `.chezmoi.kernel` | object | Contains information from `/proc/sys/kernel`. Linux only, useful for detecting specific kernels (e.g. Microsoft's WSL kernel) | +| `.chezmoi.os` | string | Operating system, e.g. `darwin`, `linux`, etc. as returned by [runtime.GOOS](https://pkg.go.dev/runtime?tab=doc#pkg-constants) | +| `.chezmoi.osRelease` | object | The information from `/etc/os-release`, Linux only, run `chezmoi data` to see its output | +| `.chezmoi.pathListSeparator` | string | The path list separator, typically `;` on Windows and `:` on other systems. Used to separate paths in environment variables. ie `/bin:/sbin:/usr/bin` | +| `.chezmoi.pathSeparator` | string | The path separator, typically `\` on windows and `/` on unix. Used to separate files and directories in a path. ie `c:\see\dos\run` | +| `.chezmoi.sourceDir` | string | The source directory | +| `.chezmoi.sourceFile` | string | The path of the template relative to the source directory | +| `.chezmoi.targetFile` | string | The absolute path of the target file for the template | +| `.chezmoi.uid` | string | The user ID | +| `.chezmoi.username` | string | The username of the user running chezmoi | +| `.chezmoi.version.builtBy` | string | The program that built the `chezmoi` executable, if set | +| `.chezmoi.version.commit` | string | The git commit at which the `chezmoi` executable was built, if set | +| `.chezmoi.version.date` | string | The timestamp at which the `chezmoi` executable was built, if set | +| `.chezmoi.version.version` | string | The version of chezmoi | +| `.chezmoi.windowsVersion` | object | Windows version information, if running on Windows | +| `.chezmoi.workingTree` | string | The working tree of the source directory | + +`.chezmoi.windowsVersion` contains the following keys populated from the +registry key `Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows +NT\CurrentVersion`. + +| Key | Type | +| --------------------------- | ------- | +| `currentBuild` | string | +| `currentMajorVersionNumber` | integer | +| `currentMinorVersionNumber` | integer | +| `currentVersion` | string | +| `displayVersion` | string | +| `editionID` | string | +| `productName` | string | + +Additional variables can be defined in the config file in the `data` section. +Variable names must consist of a letter and be followed by zero or more letters +and/or digits. diff --git a/assets/chezmoi.io/docs/reference/templates/vault-functions/vault.md b/assets/chezmoi.io/docs/reference/templates/vault-functions/vault.md new file mode 100644 index 00000000000..ddf97a1b758 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/vault-functions/vault.md @@ -0,0 +1,13 @@ +# `vault` *key* + +`vault` returns structured data from [Vault](https://www.vaultproject.io/) +using the [Vault CLI](https://www.vaultproject.io/docs/commands/) (`vault`). +*key* is passed to `vault kv get -format=json $KEY` and the output from `vault` +is parsed as JSON. The output from `vault` is cached so calling `vault` +multiple times with the same *key* will only invoke `vault` once. + +!!! example + + ``` + {{ (vault "$KEY").data.data.password }} + ``` diff --git a/assets/chezmoi.io/docs/user-guide/advanced/customize-your-source-directory.md b/assets/chezmoi.io/docs/user-guide/advanced/customize-your-source-directory.md new file mode 100644 index 00000000000..b37110efd5f --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/advanced/customize-your-source-directory.md @@ -0,0 +1,86 @@ +# Customize your source directory + +## Use a subdirectory of your dotfiles repo as the root of the source state + +By default, chezmoi uses the root of your dotfiles repo as the root of the +source state. If your source state contains many entries in its root, then your +target directory (usually your home directory) will in turn be filled with many +entries in its root as well. You can reduce the number of entries by keeping +`.chezmoiignore` up to date, but this can become tiresome. + +Instead, you can specify that chezmoi should read the source state from a +subdirectory of the source directory instead by creating a file called +`.chezmoiroot` containing the relative path to this subdirectory. + +For example, given: + +``` title="~/.local/share/chezmoi/.chezmoiroot" +home +``` + +Then chezmoi will read the source state from the `home` subdirectory of your +source directory, for example the desired state of `~/.gitconfig` will be read +from `~/.local/share/chezmoi/home/dot_gitconfig` (instead of +`~/.local/share/chezmoi/dot_gitconfig`). + +When migrating an existing chezmoi dotfiles repo to use `.chezmoiroot` you will +need to move the relevant files in to the new root subdirectory manually. You +do not need to move files that are ignored by chezmoi in all cases (i.e. are +listed in `.chezmoiignore` when executed as a template on all machines), and +you can afterwards remove their entries from `home/.chezmoiignore`. + +## Use a different version control system to git + +Although chezmoi is primarily designed to use a git repo for the source state, +it does not require git and can be used with other version control systems, such +as [fossil](https://www.fossil-scm.org/) or [pijul](https://pijul.org/). + +The version control system is used in only three places: + +* `chezmoi init` will use `git clone` to clone the source repo if it does not + already exist. +* `chezmoi update` will use `git pull` by default to pull the latest changes. +* chezmoi's auto add, commit, and push functionality use `git status`, `git + add`, `git commit` and `git push`. + +Using a different version control system (VCS) to git can be achieved in two +ways. + +Firstly, if your VCS is compatible with git's CLI, then you can set the +`git.command` configuration variable to your VCS command and set `useBuiltinGit` +to `false`. + +Otherwise, you can use your VCS to create the source directory before running +`chezmoi init`, for example: + +```console +$ fossil clone https://dotfiles.example.com/ dotfiles.fossil +$ mkdir -p .local/share/chezmoi/.git +$ cd .local/share/chezmoi +$ fossil open ~/dotfiles.fossil +$ chezmoi init --apply +``` + +!!! note + + The creation of an empty `.git` directory in the source directory is + required for chezmoi to be able to identify the work tree. + +For updates, you can set the `update.command` and `update.args` configuration +variables and `chezmoi update` will use these instead of `git pull`, for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[update] + command = "fossil" + args = ["update"] +``` + +Currently, it is not possible to override the auto add, commit, and push +behavior for non-git VCSs, so you will have to commit changes manually, for +example: + +```console +$ chezmoi cd +$ fossil add . +$ fossil commit +``` diff --git a/assets/chezmoi.io/docs/user-guide/advanced/install-packages-declaratively.md b/assets/chezmoi.io/docs/user-guide/advanced/install-packages-declaratively.md new file mode 100644 index 00000000000..e721ee29d8d --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/advanced/install-packages-declaratively.md @@ -0,0 +1,43 @@ +# Install packages declaratively + +chezmoi uses a declarative approach for the contents of dotfiles, but package +installation requires running imperative commands. However, you can simulate +declarative package installation with a combination of a `.chezmoidata` file and +a `run_onchange_` script. + +The following example uses [homebrew](https://brew.sh) on macOS, but should be +adaptable to other operating systems and package managers. + +First, create `.chezmoidata/packages.yaml` declaring the packages that you want +installed, for example: + +```yaml title="~/.local/share/chezmoi/.chezmoidata/packages.yaml" +packages: + darwin: + brews: + - 'git' + casks: + - 'google-chrome' +``` + +Second, create a `run_onchange_darwin-install-packages.sh.tmpl` script that uses +the package manager to install those packages, for example: + +``` title="~/.local/share/chezmoi/run_onchange_darwin-install-packages.sh.tmpl" +{{ if eq .chezmoi.os "darwin" -}} +#!/bin/bash + +brew bundle --no-lock --file=/dev/stdin </dev/null 2>&1 && exit + +case "$(uname -s)" in +Darwin) + # commands to install password-manager-binary on Darwin + ;; +Linux) + # commands to install password-manager-binary on Linux + ;; +*) + echo "unsupported OS" + exit 1 + ;; +esac +``` + +!!! note + + The leading `.` in `.install-password-manager.sh` is important because it tells + chezmoi to ignore `.install-password-manager.sh` when declaring the state of + files in your home directory. + +Finally, tell chezmoi to run your password manager install hook before reading +the source state: + +```toml title=".config/chezmoi/chezmoi.toml" +[hooks.read-source-state.pre] + command = ".local/share/chezmoi/.install-password-manager.sh" +``` diff --git a/assets/chezmoi.io/docs/user-guide/advanced/migrate-away-from-chezmoi.md b/assets/chezmoi.io/docs/user-guide/advanced/migrate-away-from-chezmoi.md new file mode 100644 index 00000000000..ea4685c3046 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/advanced/migrate-away-from-chezmoi.md @@ -0,0 +1,19 @@ +# Migrate away from chezmoi + +chezmoi provides several mechanisms to help you move to an alternative dotfile +manager (or even no dotfile manager at all) in the future: + +chezmoi creates your dotfiles just as if you were not using a dotfile manager at +all. Your dotfiles are regular files, directories, and symlinks. You can run +[`chezmoi purge`](../../reference/commands/purge.md) to delete all traces of +chezmoi and then, if you're migrating to a new dotfile manager, then you can use +whatever mechanism it provides to add your dotfiles to your new system. + +chezmoi has a [`chezmoi archive`](../../reference/commands/archive.md) command +that generates a tarball of your dotfiles. You can replace the contents of your +dotfiles repo with the contents of the archive and you've effectively +immediately migrated away from chezmoi. + +chezmoi has a [`chezmoi dump`](../../reference/commands/dump.md) command that +dumps the interpreted (target) state in a machine-readable form, so you can +write scripts around chezmoi. diff --git a/assets/chezmoi.io/docs/user-guide/advanced/use-chezmoi-with-watchman.md b/assets/chezmoi.io/docs/user-guide/advanced/use-chezmoi-with-watchman.md new file mode 100644 index 00000000000..e3b43357a35 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/advanced/use-chezmoi-with-watchman.md @@ -0,0 +1,48 @@ +# Use chezmoi with Watchman + +chezmoi can be used with [Watchman](https://facebook.github.io/watchman) to +automatically run `chezmoi apply` whenever your source state changes, but there +are some limitations because Watchman runs actions in the background without a +terminal. + +Firstly, Watchman spawns a server which runs actions when filesystems change. +This server reads its environment variables when it is started, typically on the +first invocation of the `watchman` command. If you use a password manager that +uses environment variables to persist login sessions, then you must login to +your password manager before you run the first `watchman` command, and your +session might eventually time out. + +Secondly, Watchman runs processes without a terminal, and so cannot run +interactive processes. For `chezmoi apply`, you can use the `--force` flag to +suppress prompts to overwrite files that have been modified since chezmoi last +wrote them. However, if any other part of `chezmoi apply` is interactive, for +example if your password manager prompts for a password, then it will not work +with Watchman. + +1. Tell watchman to watch your source directory: + + ```console + $ CHEZMOI_SOURCE_PATH="$(chezmoi source-path)" + $ watchman watch "${CHEZMOI_SOURCE_PATH}" + ``` + +2. Tell watchman to run `chezmoi apply --force` whenever your source directory +changes: + + ```console + $ watchman -j <>W: chezmoi add <file> + W->>W: chezmoi edit <file> + W-->>H: chezmoi status + W-->>H: chezmoi diff + W->>H: chezmoi apply + W->>H: chezmoi edit --apply <file> + H-->>W: chezmoi cd +``` + +## Using chezmoi across multiple machines + +* [`chezmoi init $GITHUB_USERNAME`](../reference/commands/init.md) clones your + dotfiles from GitHub into the source directory. + +* [`chezmoi init --apply $GITHUB_USERNAME`](../reference/commands/init.md) + clones your dotfiles from GitHub into the source directory and runs `chezmoi + apply`. + +* [`chezmoi update`](../reference/commands/update.md) pulls the latest changes + from your remote repo and runs `chezmoi apply`. + +* Use normal git commands to add, commit, and push changes to your remote repo. + +```mermaid +sequenceDiagram + participant H as home directory + participant W as working copy + participant L as local repo + participant R as remote repo + R->>W: chezmoi init <github-username> + R->>H: chezmoi init --apply <github-username> + R->>H: chezmoi update <github-username> + W->>L: git commit + L->>R: git push +``` + +## Working with templates + +* [`chezmoi data`](../reference/commands/data.md) prints the available template + data. + +* [`chezmoi add --template $FILE`](../reference/commands/add.md) adds `$FILE` as + a template. + +* [`chezmoi chattr +template $FILE`](../reference/commands/chattr.md) makes an + existing file a template. + +* [`chezmoi cat $FILE`](../reference/commands/cat.md) prints the target contents + of `$FILE`, without changing `$FILE`. + +* [`chezmoi execute-template`](../reference/commands/execute-template.md) is + useful for testing and debugging templates. diff --git a/assets/chezmoi.io/docs/user-guide/daily-operations.md b/assets/chezmoi.io/docs/user-guide/daily-operations.md new file mode 100644 index 00000000000..0dee2b5dbf2 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/daily-operations.md @@ -0,0 +1,131 @@ +# Daily operations + +## Pull the latest changes from your repo and apply them + +You can pull the changes from your repo and apply them in a single command: + +```console +$ chezmoi update +``` + +This runs `git pull --autostash --rebase` in your source directory and then +`chezmoi apply`. + +```mermaid +sequenceDiagram + participant H as home directory + participant W as working copy + participant L as local repo + participant R as remote repo + R->>H: chezmoi update +``` + +## Pull the latest changes from your repo and see what would change, without actually applying the changes + +Run: + +```console +$ chezmoi git pull -- --autostash --rebase && chezmoi diff +``` + +This runs `git pull --autostash --rebase` in your source directory and `chezmoi +diff` then shows the difference between the target state computed from your +source directory and the actual state. + +If you're happy with the changes, then you can run + +```console +$ chezmoi apply +``` + +to apply them. + +```mermaid +sequenceDiagram + participant H as home directory + participant W as working copy + participant L as local repo + participant R as remote repo + R->>W: chezmoi git pull + W-->>H: chezmoi diff + W->>H: chezmoi apply +``` + +## Automatically commit and push changes to your repo + +chezmoi can automatically commit and push changes to your source directory to +your repo. This feature is disabled by default. To enable it, add the following +to your config file: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[git] + autoCommit = true + autoPush = true +``` + +Whenever a change is made to your source directory, chezmoi will commit the +changes with an automatically-generated commit message (if `autoCommit` is true) +and push them to your repo (if `autoPush` is true). `autoPush` implies +`autoCommit`, i.e. if `autoPush` is true then chezmoi will auto-commit your +changes. If you only set `autoCommit` to true then changes will be committed but +not pushed. + +By default, `autoCommit` will generate a commit message based on the files +changed. You can override this by setting the `git.commitMessageTemplate` +configuration variable. For example, to have chezmoi prompt you for a commit +message each time, use: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[git] + autoCommit = true + commitMessageTemplate = "{{ promptString \"Commit message\" }}" +``` + +If your commit message is longer than fits in a string then you can set +`git.commitMessageTemplateFile` to specify a path to the commit message template +relative to the source directory, for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[git] + autoCommit = true + commitMessageTemplateFile = ".commit_message.tmpl" +``` + +Be careful when using `autoPush`. If your dotfiles repo is public and you +accidentally add a secret in plain text, that secret will be pushed to your +public repo. + +```mermaid +sequenceDiagram + participant H as home directory + participant W as working copy + participant L as local repo + participant R as remote repo + W->>L: autoCommit + W->>R: autoPush +``` + +## Install chezmoi and your dotfiles on a new machine with a single command + +chezmoi's install script can run `chezmoi init` for you by passing extra +arguments to the newly installed chezmoi binary. If your dotfiles repo is +`github.com/$GITHUB_USERNAME/dotfiles` then installing chezmoi, running +`chezmoi init`, and running `chezmoi apply` can be done in a single line of +shell: + +```console +$ sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply $GITHUB_USERNAME +``` + +If your dotfiles repo has a different name to `dotfiles`, or if you host your +dotfiles on a different service, then see the [reference manual for `chezmoi +init`](../reference/commands/init.md). + +For setting up transitory environments (e.g. short-lived Linux containers) you +can install chezmoi, install your dotfiles, and then remove all traces of +chezmoi, including the source directory and chezmoi's configuration directory, +with a single command: + +```console +$ sh -c "$(curl -fsLS get.chezmoi.io)" -- init --one-shot $GITHUB_USERNAME +``` diff --git a/assets/chezmoi.io/docs/user-guide/encryption/age.md b/assets/chezmoi.io/docs/user-guide/encryption/age.md new file mode 100644 index 00000000000..5c0a3c64ca3 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/encryption/age.md @@ -0,0 +1,76 @@ +# age + +chezmoi supports encrypting files with [age](https://age-encryption.org/). + +Generate a key using `age-keygen`: + +```console +$ age-keygen -o $HOME/key.txt +Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p +``` + +Specify age encryption in your configuration file, being sure to specify at +least the identity and one recipient: + +```toml title="~/.config/chezmoi/chezmoi.toml" +encryption = "age" +[age] + identity = "/home/user/key.txt" + recipient = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p" +``` + +chezmoi supports multiple recipients and recipient files, and multiple +identities. + +## Symmetric encryption + +To use age's symmetric encryption, specify a single identity and enable +symmetric encryption in your config file, for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +encryption = "age" +[age] + identity = "~/.ssh/id_rsa" + symmetric = true +``` + +## Symmetric encryption with a passphrase + +To use age's symmetric encryption with a passphrase, set `age.passphrase` to +`true` in your config file, for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +encryption = "age" +[age] + passphrase = true +``` + +You will be prompted for the passphrase whenever you run `chezmoi add +--encrypt` and whenever chezmoi needs to decrypt the file, for example when you +run `chezmoi apply`, `chezmoi diff`, or `chezmoi status`. + +## Builtin age encryption + +chezmoi has builtin support for age encryption which is automatically used if +the `age` command is not found in `$PATH`. + +!!! info + + The builtin age encryption does not support passphrases, symmetric + encryption, or SSH keys. + + Passphrases are not supported because chezmoi needs to decrypt files + regularly, e.g. when running a `chezmoi diff` or a `chezmoi status` + command, not just when running `chezmoi apply`. Prompting for a passphrase + each time would quickly become tiresome. + + Symmetric encryption may be supported in the future. Please [open an + issue](https://github.com/twpayne/chezmoi/issues/new?assignees=&labels=enhancement&template=02_feature_request.md&title=) + if you want this. + + SSH keys are not supported as the [age documentation explicitly recommends + not using them](https://pkg.go.dev/filippo.io/age#hdr-Key_management): + + > When integrating age into a new system, it's recommended that you only + > support X25519 keys, and not SSH keys. The latter are supported for + > manual encryption operations. diff --git a/assets/chezmoi.io/docs/user-guide/encryption/gpg.md b/assets/chezmoi.io/docs/user-guide/encryption/gpg.md new file mode 100644 index 00000000000..3ee1f2f416f --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/encryption/gpg.md @@ -0,0 +1,74 @@ +# gpg + +chezmoi supports encrypting files with [gpg](https://www.gnupg.org/). Encrypted +files are stored in the source state and automatically be decrypted when +generating the target state or editing a file contents with `chezmoi edit`. + +## Asymmetric (private/public-key) encryption + +Specify the encryption key to use in your configuration file (`chezmoi.toml`) +with the `gpg.recipient` key: + +```toml title="~/.config/chezmoi/chezmoi.toml" +encryption = "gpg" +[gpg] + recipient = "..." +``` + +chezmoi will encrypt files: + +```sh +gpg --armor --recipient $RECIPIENT --encrypt +``` + +and store the encrypted file in the source state. The file will automatically +be decrypted when generating the target state. + +## Symmetric encryption + +Specify symmetric encryption in your configuration file: + +```toml title="~/.config/chezmoi/chezmoi.toml" +encryption = "gpg" +[gpg] + symmetric = true +``` + +chezmoi will encrypt files: + +```sh +gpg --armor --symmetric +``` + +## Encrypting files with a passphrase + +If you want to encrypt your files with a passphrase, but don't mind the +passphrase being stored in plaintext on your machines, then you can use the +following configuration: + +``` title="~/.local/share/chezmoi/.chezmoi.toml.tmpl" +{{ $passphrase := promptStringOnce . "passphrase" "passphrase" -}} + +encryption = "gpg" +[data] + passphrase = {{ $passphrase | quote }} +[gpg] + symmetric = true + args = ["--batch", "--passphrase", {{ $passphrase | quote }}, "--no-symkey-cache"] +``` + +This will prompt you for the passphrase the first time you run `chezmoi init` on +a new machine, and then remember the passphrase in your configuration file. + +## Muting gpg output + +Since gpg sends some info messages to stderr instead of stdout, you will see +some output even if you redirect stdout to `/dev/null`. + +You can mute this by adding `--quiet` to the `gpg.args` key in your +configuration: + +```toml title="~/.local/share/chezmoi/.chezmoi.toml.tmpl" +[gpg] + args = ["--quiet"] +``` diff --git a/assets/chezmoi.io/docs/user-guide/encryption/index.md b/assets/chezmoi.io/docs/user-guide/encryption/index.md new file mode 100644 index 00000000000..932ddd67bb8 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/encryption/index.md @@ -0,0 +1,16 @@ +# Encryption + +chezmoi supports encrypting files with [age](https://age-encryption.org) +and [gpg](https://www.gnupg.com/). + +Encrypted files are stored in ASCII-armored format in the source directory with +the `encrypted_` attribute and are automatically decrypted when needed. + +Add files to be encrypted with the `--encrypt` flag, for example: + +```console +$ chezmoi add --encrypt ~/.ssh/id_rsa +``` + +`chezmoi edit` will transparently decrypt the file before editing and +re-encrypt it afterwards. diff --git a/assets/chezmoi.io/docs/user-guide/encryption/rage.md b/assets/chezmoi.io/docs/user-guide/encryption/rage.md new file mode 100644 index 00000000000..09599de28e6 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/encryption/rage.md @@ -0,0 +1,13 @@ +# rage + +chezmoi supports encrypting files with [rage](https://str4d.xyz/rage). + +To use rage, set `age.command` to `rage` in your configuration file, for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +encryption = "age" +[age] + command = "rage" +``` + +Then, configure chezmoi as you would for [age](age.md). diff --git a/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/design.md b/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/design.md new file mode 100644 index 00000000000..a0b04fb7ed4 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/design.md @@ -0,0 +1,374 @@ +# Design + +## Do I have to use `chezmoi edit` to edit my dotfiles? + +No. `chezmoi edit` is a convenience command that has a couple of useful +features, but you don't have to use it. + +You can also run `chezmoi cd` and then just edit the files in the source state +directly. After saving an edited file you can run `chezmoi diff` to check what +effect the changes would have, and run `chezmoi apply` if you're happy with +them. If there are inconsistencies that you want to keep, then `chezmoi +merge-all` will help you resolve any differences. + +`chezmoi edit` provides the following useful features: + +* The arguments to `chezmoi edit` are the files in their target location, so you + don't have to think about source state attributes and your editor's syntax + highlighting will work. + +* If the dotfile is encrypted in the source state, then `chezmoi edit` will + decrypt it to a private directory, open that file in your `$EDITOR`, and then + re-encrypt the file when you quit your editor. This makes encryption + transparent. + +* With the `--diff` and `--apply` options you can see what would change and + apply those changes without having to run `chezmoi diff` or `chezmoi apply`. + +* If you have configured git auto commits or git auto pushes then `chezmoi edit` + will create commits and push them for you. + +If you chose to edit files in the source state and you're using VIM then +[`github.com/alker0/chezmoi.vim`](https://github.com/alker0/chezmoi.vim) gives +you syntax highlighting, however you edit your files. Besides using the +plugin, you can use modeline to tell VIM the correct filetype. For example, +put `# vim: filetype=zsh` at the top of `dot_zshrc`, and VIM will treat +`dot_zshrc` as zsh file. + +## Why doesn't chezmoi use symlinks like GNU Stow? + +Symlinks are first class citizens in chezmoi: chezmoi supports creating them, +updating them, removing them, and even more advanced features not found in other +dotfile managers like having the same symlink point to different targets on +different machines by using a template. + +With chezmoi, you only use a symlink where you really need a symlink, in +contrast to some other dotfile managers (e.g. GNU Stow) which require the use +of symlinks as a layer of indirection between a dotfile's location (which can +be anywhere in your home directory) and a dotfile's content (which needs to be +in a centralized directory that you manage with version control). chezmoi +solves this problem in a different way. + +Instead of using a symlink to redirect from the dotfile's location to the +centralized directory, chezmoi generates the dotfile as a regular file in its +final location from the contents of the centralized directory. This approach +allows chezmoi to provide features that are not possible when using symlinks, +for example having files that are encrypted, executable, private, or templates. + +There is nothing special about dotfiles managed by chezmoi whereas dotfiles +managed with GNU Stow are special because they're actually symlinks to somewhere +else. + +The only advantage to using GNU Stow-style symlinks is that changes that you +make to the dotfile's contents in the centralized directory are immediately +visible whenever you save them, whereas chezmoi currently requires you to pass +the `--watch` flag to `chezmoi edit` or set `edit.watch` to `true` in your +configuration file. + +If you really want to use symlinks, then chezmoi provides a [symlink +mode](../../reference/target-types.md#symlink-mode) which uses symlinks where +possible. This configures chezmoi to work like GNU Stow and have it create a set +of symlinks back to a central directory, but this currently requires a bit of +manual work (as described in +[#167](https://github.com/twpayne/chezmoi/issues/167)). chezmoi might get some +automation to help (see [#886](https://github.com/twpayne/chezmoi/issues/886) +for example) but it does need some convincing use cases that demonstrate that a +symlink from a dotfile's location to its contents in a central directory is +better than just having the correct dotfile contents. + +## What are the limitations of chezmoi's symlink mode? + +In symlink mode chezmoi replaces targets with symlinks to the source directory +if the target is a regular file and is not encrypted, executable, private, +or a template. + +Symlinks cannot be used for encrypted files because the source state contains +the ciphertext, not the plaintext. + +Symlinks cannot be used for executable files as the executable bit would need +to be set on the file in the source directory and chezmoi uses only regular +files and directories in its source state for portability across operating +systems. This may change in the future. + +Symlinks cannot be used for private files because git does not persist group +and world permission bits. + +Symlinks cannot be used for templated files because the source state contains +the template, not the result of executing the template. + +Symlinks cannot be used for entire directories because of chezmoi's use of +attributes in the filename mangles entries in the directory, directories might +have the `exact_` attribute and contain empty files, and the directory's +entries might not be usable with symlinks. + +In symlink mode, running `chezmoi add` does not immediately replace the targets +with a symlink. You must run `chezmoi apply` to create the symlinks. + +## Why does chezmoi use weird filenames? + +There are a number of criticisms of how chezmoi uses filenames: + +1. The long source file names are weird and verbose. +2. Not all possible file permissions can be represented. +3. Everything is in a single directory, which can end up containing many + entries. + +chezmoi's decision to store metadata in filenames is a deliberate, practical, +compromise. + +Firstly, almost all programs store metadata in filenames: the filename's +extension. chezmoi extends the filename to storing metadata in attributes in the +filename's prefix as well. + +The `dot_` attribute makes it transparent which dotfiles are managed by chezmoi +and which files are ignored by chezmoi. chezmoi ignores all files and +directories that start with `.` so no special whitelists are needed for version +control systems and their control files (e.g. `.git` and `.gitignore`). + +chezmoi needs per-file metadata to know how to interpret the source file's +contents, for example to know when the source file is a template or if the +file's contents are encrypted. By storing this metadata in the filename, the +metadata is unambiguously associated with a single file and adding, updating, +or removing a single file touches only a single file in the source state. +Changes to the metadata (e.g. `chezmoi chattr +template $TARGET`) are simple +file renames and isolated to the affected file. + +If chezmoi were to, say, use a common configuration file listing which files +were templates and/or encrypted, then changes to any file would require updates +to the common configuration file. Automating updates to configuration files +requires a round trip (read config file, update config, write config) and it is +not always possible preserve comments and formatting. + +chezmoi's attributes of `executable_`, `private_`, and `readonly_` allow the +file permissions `0o644`, `0o755`, `0o600`, `0o700`, `0o444`, `0o555`, `0o400`, +and `0o500` to be represented. Directories can only have permissions `0o755`, +`0o700`, or `0o500`. In practice, these cover all permissions typically used +for dotfiles. If this does cause a genuine problem for you, please [open an +issue on GitHub](https://github.com/twpayne/chezmoi/issues/new/choose). + +File permissions and modes like `executable_`, `private_`, `readonly_`, and +`symlink_` could also be stored in the filesystem, rather than in the filename. +However, this requires the permissions to be preserved and handled by the +underlying version control system and filesystem. chezmoi provides first-class +support for Windows, where the `executable_` and `private_` attributes have no +direct equivalents and symbolic links are not always permitted. By using +regular files and directories, chezmoi avoids variations in the operating +system, version control system, and filesystem making it both more robust and +more portable. + +chezmoi uses a 1:1 mapping between entries in the source state and entries in +the target state. This mapping is bi-directional and unambiguous. + +However, this also means that dotfiles that in the same directory in the target +state must be in the same directory in the source state. In particular, every +entry managed by chezmoi in the root of your home directory has a corresponding +entry in the root of your source directory, which can mean that you end up with +a lot of entries in the root of your source directory. This can be mitigated by +using `.chezmoiroot` file. + +If chezmoi were to permit, say, multiple separate source directories (so you +could, say, put `dot_bashrc` in a `bash/` subdirectory, and `dot_vimrc` in a +`vim/` subdirectory, but have `chezmoi apply` map these to `~/.bashrc` and +`~/.vimrc` in the root of your home directory) then the mapping between source +and target states is no longer bidirectional nor unambiguous, which +significantly increases complexity and requires more user interaction. For +example, if both `bash/dot_bashrc` and `vim/dot_bashrc` exist, what should be +the contents of `~/.bashrc`? If you run `chezmoi add ~/.zshrc`, should +`dot_zshrc` be stored in the source `bash/` directory, the source `vim/` +directory, or somewhere else? How does the user communicate their preferences? + +chezmoi has many users and any changes to the source state representation must +be backwards-compatible. + +In summary, chezmoi's source state representation is a compromise with both +advantages and disadvantages. Changes to the representation will be considered, +but must meet the following criteria, in order of importance: + +1. Be fully backwards-compatible for existing users. +2. Fix a genuine problem encountered in practice. +3. Be independent of the underlying operating system, version control system, + and filesystem. +4. Not add significant extra complexity to the user interface or underlying + implementation. + +## Can chezmoi support multiple sources or multiple source states? + +With some dotfile managers, dotfiles can be distributed across multiple +directories or even multiple repos. For example, the user might have one +directory per application, or separate repos for home and work configurations, +or even separate git submodules for different applications. These can be +considered multiple sources of truth for the target state. This, however, comes +with complications: + +1. Multiple sources of truth complicate the user interface. When running + `chezmoi add $FILE`, which source should `$FILE` be added to? + +2. Multiple sources of truth do not compose easily if target files overlap. For + example, if you have two sources, both of which need to set an environment + variable in `.bashrc`, how do you handle this when both, only one, or + neither source might be activated? What if the sources are mutually + exclusive, e.g. if the VIM source and the Emacs source both want to set the + `$EDITOR` environment variable? + +3. Multiple sources of truth are not always independent. Related to the + previous point, consider a source that adds an applications's configuration + files and shell completions. Should the shell completions be part of the + applications's source or of the shell's source? + +chezmoi instead makes the opinionated choice to use a single source of truth, +i.e. a single branch in a single git repo. Using a single source of truth +avoids the inherent complexity and ambiguity of multiple sources. + +chezmoi provides mechanisms like templates (for minor differences), +`.chezmoiignore` (for controlling the presence or otherwise of complete files +and directories), and password manager integration (so secrets never need to be +stored in a repo) handle machine-to-machine differences. Externals make it easy +to pull in dotfiles from third-party sources. + +That said, if you are keen to use multiple sources of truth with chezmoi, you +have a number of options with some scripting around chezmoi. + +Firstly, you can run `chezmoi apply` with different arguments to the `--config` +and `--source` flags which will apply to the same destination. So that you only +have to type one command you can wrap this in a shell function, for example: + +```bash +chezmoi-apply() { + chezmoi apply --config ~/.config/chezmoi-home/chezmoi.toml \ + --source ~/.local/share/chezmoi-home && \ + chezmoi apply --config ~/.config/chezmoi-work/chezmoi.toml \ + --source ~/.local/share/chezmoi-work +} +``` + +If you want to generate multiple configuration files with `chezmoi init` then +you will need the `--config-path` flag. For more advanced use, use the +`--destination`, `--cache`, and `--persistent-state` flags. + +Secondly, you can assemble a single source state from multiple sources and then +use `chezmoi apply`. For example, if you have multiple source states in +subdirectories of `~/.dotfiles`: + +```bash +#!/bin/bash + +# create a combined source state in a temporary directory +combined_source="$(mktemp -d)" + +# remove the temporary source state on exit +trap 'rm -rf -- "${combined_source}"' INT TERM + +# copy files from multiple sources into the temporary source state +for source in $HOME/.dotfiles/*; do + cp -r "${source}"/* "${combined_source}" +done + +# apply the temporary source state +chezmoi apply --source "${combined_source}" +``` + +Thirdly, you can use a `run_` script to invoke a second instance of chezmoi, +[as used by +@felipecrs](https://github.com/felipecrs/dotfiles/blob/8a7840efdeff1a45069f47e5b2e558dc9812712d/home/.chezmoiscripts/run_after_20-run-rootmoi.sh.tmpl). + +## Why does `chezmoi cd` spawn a shell instead of just changing directory? + +`chezmoi cd` spawns a shell because it is not possible for a program to change +the working directory of its parent process. You can add a shell function instead: + +```bash +chezmoi-cd() { + cd $(chezmoi source-path) +} +``` + +Typing `chezmoi-cd` will then change the directory of your current shell to +chezmoi's source directory. + +## Why are the `prompt*` functions only available in config file templates? + +chezmoi regularly needs to execute templates to determine the target contents +of files. For example, templates are executed for the `apply`, `diff`, and +`status` commands, amongst many others. Having to interactively respond each +time would quickly become tiresome. Therefore, chezmoi only provides these +functions when generating a config file from a config file template (e.g. when +you run `chezmoi init` or `chezmoi --init apply`). + + +## Why not use Ansible/Chef/Puppet/Salt, or similar to manage my dotfiles instead? + +Whole system management tools are more than capable of managing your dotfiles, +but they are large systems that entail several disadvantages. Compared to whole +system management tools, chezmoi offers: + +* Small, focused feature set designed for dotfiles. There's simply less to learn + with chezmoi compared to whole system management tools. + +* Easy installation and execution on every platform, without root access. + Installing chezmoi requires only copying a single binary file with no external + dependencies. Executing chezmoi just involves running the binary. In contrast, + installing and running a whole system management tool typically requires + installing a scripting language runtime, several packages, and running a + system service, all typically requiring root access. + +chezmoi's focus and simple installation means that it runs almost everywhere: +from tiny ARM-based Linux systems to Windows desktops, from inside lightweight +containers to FreeBSD-based virtual machines in the cloud. + +## Can I use chezmoi to manage files outside my home directory? + +In practice, yes, you can, but this usage is strongly discouraged beyond using +your system's package manager to install the packages you need. + +chezmoi is designed to operate on your home directory, and is explicitly not a +full system configuration management tool. That said, there are some ways to +have chezmoi manage a few files outside your home directory. + +chezmoi's scripts can execute arbitrary commands, so you can use a `run_` script +that is run every time you run `chezmoi apply`, to, for example: + +* Make the target file outside your home directory a symlink to a file managed + by chezmoi in your home directory. + +* Copy a file managed by chezmoi inside your home directory to the target file. + +* Execute a template with `chezmoi execute-template --output=$FILENAME + template` where `$FILENAME` is outside the target directory. + +chezmoi executes all scripts as the user executing chezmoi, so you may need to +add extra privilege elevation commands like `sudo` or `PowerShell start -verb +runas -wait` to your script. + +chezmoi, by default, operates on your home directory but this can be overridden +with the `--destination` command line flag or by specifying `destDir` in your +config file, and could even be the root directory (`/` or `C:\`). This allows +you, in theory, to use chezmoi to manage any file in your filesystem, but this +usage is extremely strongly discouraged. + +If your needs extend beyond modifying a handful of files outside your target +system, then existing configuration management tools like +[Puppet](https://puppet.com/), [Chef](https://chef.io/), +[Ansible](https://www.ansible.com/), and [Salt](https://www.saltstack.com/) are +much better suited - and of course can be called from a chezmoi `run_` script. +Put your Puppet Manifests, Chef Recipes, Ansible Modules, and Salt Modules in a +directory ignored by `.chezmoiignore` so they do not pollute your home +directory. + +## What inspired chezmoi? + +chezmoi was inspired by [Puppet](https://puppet.com/), but was created because +Puppet is an overkill for managing your personal configuration files. The focus +of chezmoi will always be personal home directory management. If your needs +grow beyond that, switch to a whole system configuration management tool. + +## Where does the name "chezmoi" come from? + +"chezmoi" splits to "chez moi" and pronounced /ʃeɪ mwa/ (shay-moi) meaning "at +my house" in French. It's seven letters long, which is an appropriate length for +a command that is only run occasionally. If you prefer a shorter command, add an +alias to your shell configuration, for example: + +```sh +alias cz=chezmoi +``` + diff --git a/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/encryption.md b/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/encryption.md new file mode 100644 index 00000000000..41b3191e0a1 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/encryption.md @@ -0,0 +1,145 @@ +# Encryption + +## How do I configure chezmoi to encrypt files but only request a passphrase the first time `chezmoi init` is run? + +The following steps use [age](https://age-encryption.org/) for encryption. + +This can be achieved with the following process: + +1. Generate an age private key. +2. Encrypt the private key with a passphrase. +3. Configure chezmoi to decrypt the private key if needed. +4. Configure chezmoi to use the private key. +5. Add encrypted files. + +First, change to chezmoi's root directory: + +```console +$ chezmoi cd ~ +``` + +Generate an age private key encrypted with a passphrase in the file +`key.txt.age` with the command: + +```console +$ age-keygen | age --armor --passphrase > key.txt.age +Public key: age193wd0hfuhtjfsunlq3c83s8m93pde442dkcn7lmj3lspeekm9g7stwutrl +Enter passphrase (leave empty to autogenerate a secure one): +Confirm passphrase: +``` + +Use a strong passphrase and make a note of the public key +(`age193wd0hfuhtjfsunlq3c83s8m93pde442dkcn7lmj3lspeekm9g7stwutrl` in this case). + +Add `key.txt.age` to `.chezmoiignore` so that chezmoi does not try to create it: + +```console +$ echo key.txt.age >> .chezmoiignore +``` + +Configure chezmoi to decrypt the passphrase-encrypted private key if needed: + +```console +$ cat > run_once_before_decrypt-private-key.sh.tmpl <> .chezmoi.toml.tmpl <..." to include in what will be committed) + .chezmoi.toml.tmpl + .chezmoiignore + key.txt.age + run_once_before_decrypt-private-key.sh.tmpl + +nothing added to commit but untracked files present (use "git add" to track) +``` + +If you're happy with the changes you can commit them. All four files should be +committed. + +Add files that you want to encrypt using the `--encrypt` argument to `chezmoi +add`, for example: + +```console +$ chezmoi add --encrypt ~/.ssh/id_rsa +``` + +When you run `chezmoi init` on a new machine you will be prompted to enter your +passphrase once to decrypt `key.txt.age`. Your decrypted private key will be +stored in `~/.config/chezmoi/key.txt`. + +## How to re-encrypt encrypted files + +To rotate from an expired GPG key to its replacement, or change from GPG to age +encryption, the following steps can be used: + +1. Make sure you have applied all encrypted files (e.g. `chezmoi apply` decrypts + files and places them in their destinations). +2. Update chezmoi configuration to use the new encryption method (examples: + [gpg](../encryption/gpg.md), [age](../encryption/age.md), [age with one-time + passphrase](#how-do-i-configure-chezmoi-to-encrypt-files-but-only-request-a-passphrase-the-first-time-chezmoi-init-is-run)). +3. Remove all encrypted files from the state via `chezmoi forget` or `chezmoi unmanage`. +4. Add them back with `chezmoi add --encrypt`. + +### Example: Migrate from GPG to age + +Update chezmoi configuration to use age encryption (with `chezmoi edit-config` +or manually editing the corresponding template): + +```diff +- encryption = "gpg" +- [gpg] +- recipient = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" ++ encryption = "age" ++ [age] ++ recipient = "age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ++ identity = "/home/user/key.txt" +``` + +Re-encrypt the files with a script like this: + +```bash +for encrypted_file in $(chezmoi managed --include encrypted --path-style absolute) +do + # optionally, add --force to avoid prompts + chezmoi forget "$encrypted_file" + + # strip the .asc extension + decrypted_file="${encrypted_file%.asc}" + + chezmoi add --encrypt "$decrypted_file" +done +``` diff --git a/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/general.md b/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/general.md new file mode 100644 index 00000000000..9cdedc09509 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/general.md @@ -0,0 +1,31 @@ +# General + +## What other questions have been asked about chezmoi? + +See the +[issues](https://github.com/twpayne/chezmoi/issues?utf8=%E2%9C%93&q=is%3Aissue+sort%3Aupdated-desc+label%3Asupport) +and [discussions](https://github.com/twpayne/chezmoi/discussions). + +## Where do I ask a question that isn't answered here? + +Please [open an issue on +GitHub](https://github.com/twpayne/chezmoi/issues/new/choose) or [start a +discussion](https://github.com/twpayne/chezmoi/discussions/new). + +## I like chezmoi. How do I say thanks? + +Thank you! chezmoi was written to scratch a personal itch, and I'm very happy +that it's useful to you. Please give [chezmoi a star on +GitHub](https://github.com/twpayne/chezmoi/stargazers), and if you're happy to +share your public dotfile repo then [tag it with +`chezmoi`](https://github.com/topics/chezmoi?o=desc&s=updated). + +If you write an article or give a talk on chezmoi please inform the author (e.g. +by [opening an issue](https://github.com/twpayne/chezmoi/issues/new/choose)) so +it can be added to chezmoi's [articles](../../links/articles.md), +[podcasts](../../links/podcasts.md), and [videos](../../links/videos.md) pages. + +[Contributions are very welcome](../../developer-guide/contributing-changes.md) +and every [bug report, support request, and feature +request](https://github.com/twpayne/chezmoi/issues/new/choose) helps make +chezmoi better. Thank you :) diff --git a/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/troubleshooting.md b/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/troubleshooting.md new file mode 100644 index 00000000000..62232b1afcb --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/troubleshooting.md @@ -0,0 +1,285 @@ +# Troubleshooting + +## How can I quickly check for problems with chezmoi on my machine? + +Run: + +```console +$ chezmoi doctor +``` + +Anything `ok` is fine, anything `warning` is only a problem if you want to use +the related feature, and anything `error` indicates a definite problem. + +## A specific command is not behaving as I expect. How can I debug it? + +The `--verbose` flag makes chezmoi to print extra information about what it +is doing. + +The `--debug` flag makes chezmoi print very detailed step by step information. + +## The output of `chezmoi diff` is broken and does not contain color. What could be wrong? + +By default, chezmoi's diff output includes ANSI color escape sequences (e.g. +`ESC[37m`) and is piped into your pager (by default `less`). chezmoi assumes +that your pager passes through the ANSI color escape sequences, as configured on +many systems, but not all. If your pager does not pass through ANSI color escape +sequences then you will see monochrome diff output with uninterpreted ANSI color +escape sequences. + +This can typically by fixed by setting the environment variable + +```console +$ export LESS=-R +``` + +which instructs `less` to display "raw" control characters via the `-R` / +`--RAW-CONTROL-CHARS` option. + +You can also set the `pager` configuration variable in your config file, for +example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +pager = "less -R" +``` + +If you have set a different pager (via the `pager` configuration variable or +`PAGER` environment variable) then you must ensure that it passes through raw +control characters. Alternatively, you can use the `--color=false` option to +chezmoi to disable colors or the `--no-pager` option to chezmoi to disable the +pager. + +## Why do I get a blank buffer or empty file when running `chezmoi edit`? + +In this case, `chezmoi edit` typically prints a warning like: + +``` +chezmoi: warning: $EDITOR $TMPDIR/$FILENAME: returned in less than 1s +``` + +`chezmoi edit` performs a bit of magic to improve the experience of editing +files in the source state by invoking your editor with filenames in a temporary +directory that look like filenames in your home directory. What's happening +here is that your editor command is exiting immediately, so chezmoi thinks +you've finished editing and so removes the temporary directory, but actually +your editor command has forked a edit process in the background, and that edit +process opens a now non-existent file. + +To fix this you have to configure your editor command to remain in the +foreground until you have finished editing the file, so chezmoi knows when to +remove the temporary directory. + +=== "VIM" + + Pass the `-f` flag, e.g. by setting the `edit.flags` configuration variable + to `["-f"]`, or by setting the `EDITOR` environment variable to include the + `-f` flag, e.g. `export EDITOR="vim -f"`. + +=== "VSCode" + + Pass the `--wait` flag, e.g. by setting the `edit.flags` configuration + variable to `["--wait"]` or by setting the `EDITOR` environment variable to + include the `--wait` flag, e.g. `export EDITOR="code --wait"`. + +The "bit of magic" that `chezmoi edit` performs includes: + +* `chezmoi edit` makes the filename opened by your editor more closely match + the target filename, which can help your editor choose the correct syntax + highlighting. For example, if you run `chezmoi edit ~/.zshrc`, your editor is + be opened with `$TMPDIR/.zshrc` but you'll actually be editing + `~/.local/share/chezmoi/dot_zshrc`. Under the hood, chezmoi creates a + hardlink in a temporary directory to the file in your source directory, so + even though your editor thinks it's editing `.zshrc`, it is really editing + `dot_zshrc` in your source directory. + +* If the source file is encrypted then `chezmoi edit` transparently decrypts + and re-encrypts the file for you. Specifically, chezmoi decrypts the file + into a private temporary directory and open your editor with the decrypted + file, and re-encrypts the file when you exit your editor. + +* If the source file is a template, then `chezmoi edit` preserves the `.tmpl` + extension. + +## chezmoi makes `~/.ssh/config` group writeable. How do I stop this? + +By default, chezmoi uses your system's umask when creating files. On most +systems the default umask is `022` but some systems use `002`, which means +that files and directories are group writeable by default. + +You can override this for chezmoi by setting the `umask` configuration variable +in your configuration file, for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +umask = 0o022 +``` + +!!! note + + This will apply to all files and directories that chezmoi manages and will + ensure that none of them are group writeable. It is not currently possible + to control group write permissions for individual files or directories. + Please [open an issue on + GitHub](https://github.com/twpayne/chezmoi/issues/new?assignees=&labels=enhancement&template=02_feature_request.md&title=) + if you need this. + +## chezmoi reports `chezmoi: user: lookup userid NNNNN: input/output error` + +This is likely because the chezmoi binary you are using was statically compiled +with [musl](https://musl.libc.org/) and the machine you are running on uses +LDAP or NIS. + +The immediate fix is to use a package built for your distribution (e.g a `.deb` +or `.rpm`) which is linked against glibc and includes LDAP/NIS support instead +of the statically-compiled binary. + +If the problem still persists, then please [open an issue on +GitHub](https://github.com/twpayne/chezmoi/issues/new/choose). + +## chezmoi reports `chezmoi: timeout` or `chezmoi: timeout obtaining persistent state lock` + +chezmoi will report this when it is unable to lock its persistent state +(`~/.config/chezmoi/chezmoistate.boltdb`), typically because another instance of +chezmoi is currently running and holding the lock. + +This can happen, for example, if you have a `run_` script that invokes +`chezmoi`, or are running chezmoi in another window. + +Under the hood, chezmoi uses [bbolt](https://github.com/etcd-io/bbolt) which +permits multiple simultaneous readers, but only one writer (with no readers). + +Commands that take a write lock include `add`, `apply`, `edit`, `forget`, +`import`, `init`, `state`, `unmanage`, and `update`. Commands that take a read +lock include `diff`, `status`, and `verify`. + +## chezmoi reports `chezmoi: fork/exec /tmp/XXXXXXXXXX.XX: exec format error` when executing a template script + +This error occurs when you have a newline before the `#!` in your script. +Suppress the newline by including a `-` before the closing `}}` on the first +line. + +For example, if your template script begins with + +``` +{{ if eq .chezmoi.os "linux" }} +#!/bin/sh +``` + +change this to + +``` +{{ if eq .chezmoi.os "linux" -}} +#!/bin/sh +``` + +## chezmoi reports `chezmoi: fork/exec /tmp/XXXXXXXXXX.XX: permission denied` when executing a script + +This error occurs when your temporary directory is mounted with the `noexec` +option. + +As chezmoi scripts can be templates, encrypted, or both, chezmoi needs to write +the final script's contents to a file so that it can be executed by the +operating system. By default, chezmoi will use `$TMPDIR` for this. + +You can change the temporary directory into which chezmoi writes and executes +scripts with the `scriptTempDir` configuration variable. For example, to use a +subdirectory of your home directory you can use: + +```toml title="~/.config/chezmoi/chezmoi.toml" +scriptTempDir = "~/tmp" +``` + +## chezmoi reports `chezmoi: mkdir xxxxx: no such file or directory` when trying to manage file or directory + +This error occurs when you try to add directory/file to be managed via chezmoi +but the same directory is only listed in `.chezmoiexternal.$FORMAT`. + +A workaround can be applied in a such case via manually creating import +directory in chezmoi source directory (typically `~/.local/share/chezmoi`) and +create `.keep` file. + +For example, if `.chezmoiexternal.toml` has the configuration: + +```toml +[".config/nvim"] + type = "git-repo" + url = "https://github.com/NvChad/NvChad.git" + refreshPeriod = "168h" + [".config/nvim".pull] + args = ["--ff-only"] +``` + +Now `chezmoi add ~/.config/direnv/direnvrc` will raise the error: + +``` +chezmoi: mkdir /home//.local/share/chezmoi/dot_config/direnv: no such file or directory +``` + +But the workaround can be applied: + +```console +$ chezmoi cd +$ mkdir -p dot_config/ +$ touch dot_config/.keep +``` + +Now once that done `chezmoi add ~/.config/direnv/direnvrc` should work. For +reference see [this issue](https://github.com/twpayne/chezmoi/issues/2006) + +## chezmoi reports `read /dev/stdin: permission denied` or `write /dev/stdout: permission denied` when I redirect standard input or standard output + +This error occurs when you [installed chezmoi with +snap](https://snapcraft.io/chezmoi) and is caused by a long-standing [bug in +snap](https://bugs.launchpad.net/ubuntu/+source/snapd/+bug/1849753). + +This is not a bug in chezmoi and there is nothing that chezmoi can do about +this. However, there are two workarounds: + +Firstly, you can use alternatives to shell redirection. For standard input: + +```console +$ chezmoi $COMMAND <$FILENAME # fails +$ cat $FILENAME | chezmoi $COMMAND # succeeds +``` + +For standard output: + +```console +$ chezmoi $COMMAND >$FILENAME # fails +$ chezmoi $COMMAND -o $FILENAME # succeeds +$ chezmoi $COMMAND --output=$FILENAME # succeeds +$ chezmoi $COMMAND | tee $FILENAME >/dev/null # succeeds +``` + +Secondly, you can install chezmoi with any of the [many supported install +methods](../../install.md) instead of snap. + +## chezmoi reports `fork/exec ...: no such file or directory` when running scripts on Nix or Termux + +You are likely using a hardcoded script interpreter in the shebang line of your +scripts, e.g. + +```bash +#!/bin/bash +``` + +`/bin/bash` does not exist on Nix or Termux. You must update the shebang line to point +to the actual bash interpreter. The easiest way to do this is make the script a +template and use the `lookPath` template function, for example: + +``` +#!{{ lookPath "bash" }} +``` + +Alternatively, you can use the actual path to `bash` on your system, for example: + +=== "Nix" + + ```bash + #!/usr/bin/env bash + ``` + +=== "Termux" + + ```bash + #!/data/data/com.termux/files/usr/bin/bash + ``` diff --git a/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/usage.md b/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/usage.md new file mode 100644 index 00000000000..8953b45ffec --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/frequently-asked-questions/usage.md @@ -0,0 +1,139 @@ +# Usage + +## How do I edit my dotfiles with chezmoi? + +There are five popular approaches: + +1. Use `chezmoi edit $FILE`. This will open the source file for `$FILE` in your + editor, including opening the template if the file is templated and + transparently decrypting and re-encrypting it if it is encrypted. For extra + ease, use `chezmoi edit --apply $FILE` to apply the changes when you quit + your editor, and `chezmoi edit --watch $FILE` to apply the changes whenever + you save the file. + +2. Use `chezmoi cd` and edit the files in the source directory directly. Run + `chezmoi diff` to see what changes would be made, and `chezmoi apply` to make + the changes. + +3. If your editor supports opening directories, run `chezmoi edit` with no + arguments to open the source directory. + +4. Edit the file in your home directory, and then either re-add it by running + `chezmoi add $FILE` or `chezmoi re-add`. + +5. Edit the file in your home directory, and then merge your changes with source + state by running `chezmoi merge $FILE`. + + !!! note + + `re-add` doesn't work with templates. + +## 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? + +Until you run `chezmoi apply` your modified `~/.zshrc` will remain in place. +When you run `chezmoi apply` chezmoi will detect that `~/.zshrc` has changed +since chezmoi last wrote it and prompt you what to do. You can resolve +differences with a merge tool by running `chezmoi merge ~/.zshrc`. + +## 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? + +`chezmoi unmanaged` will list everything not managed by chezmoi. You can add +entire directories with `chezmoi add`. + +## How can I tell what dotfiles in my home directory are currently managed by chezmoi? + +`chezmoi managed` will list everything managed by chezmoi. + +## 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)? + +By default, chezmoi ignores everything that you haven't explicitly added. If you +have files in your source directory that you don't want added to your +destination directory when you run `chezmoi apply` add their names to a file +called `.chezmoiignore` in the source state. + +Patterns are supported, and you can change what's ignored from machine to +machine. The full usage and syntax is described in the [reference +manual](../../reference/special-files-and-directories/chezmoiignore.md). + +## 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? + +Yes. Running `chezmoi add` will update the source state with the target. To see +diffs of what would change, without actually changing anything, use `chezmoi +diff`. + +## Once I've made a change to the source directory, how do I commit it? + +You have several options: + +* `chezmoi cd` opens a shell in the source directory, where you can run your + usual version control commands, like `git add` and `git commit`. + +* `chezmoi git` runs `git` in the source + directory and pass extra arguments to the command. If you're passing any + flags, you'll need to use `--` to prevent chezmoi from consuming them, for + example `chezmoi git -- commit -m "Update dotfiles"`. + +* You can configure chezmoi to automatically commit and push changes to your + source state, as [described in the how-to + guide](../daily-operations.md#automatically-commit-and-push-changes-to-your-repo). + +## I've made changes to both the destination state and the source state that I want to keep. How can I keep them both? + +`chezmoi merge` will open a merge tool to resolve differences between the source +state, target state, and destination state. Copy the changes you want to keep in +to the source state. + +## Can I use chezmoi to manage my shell history across multiple machines? + + +No. Every change in a file managed by chezmoi requires an explicit command to +record it (e.g. `chezmoi add`) or apply it somewhere else (e.g. `chezmoi +update`), and is recorded as a commit in your dotfiles repository. Creating a +commit every time a command is entered would quickly become cumbersome. This +makes chezmoi unsuitable for sharing changes to rapidly-changing files like +shell histories. + +Instead, consider using a dedicated tool for sharing shell history across +multiple machines, like [`atuin`](https://atuin.sh/). You can use chezmoi to +install and configure atuin. + +## How do I install pre-requisites for templates? + +If you have a template that depends on some other tool, like `curl`, you may need +to install it before chezmoi renders the template. + +To do so, use a `run_before` script that is **not** a template. Something like: + +```bash title="run_before_00-install-pre-requisites.sh" +#!/bin/bash + +set -eu + +# Install curl if it's not already installed +if ! command -v curl >/dev/null; then + sudo apt update + sudo apt install -y curl +fi +``` + +Chezmoi will make sure to execute it before templating other files. + +!!! tip + + You can [use `scriptEnv` to inject data into your scripts through environment + variables](../../user-guide/use-scripts-to-perform-actions.md#set-environment-variables). + +## How do I enable shell completions? + +chezmoi includes shell completions for +[`bash`](https://www.gnu.org/software/bash/), [`fish`](https://fishshell.com/), +[PowerShell](https://learn.microsoft.com/en-us/powershell/), and +[`zsh`](https://zsh.sourceforge.io/). If you have installed chezmoi via your +package manager then the shell completion should already be installed. Please +[open an issue](https://github.com/twpayne/chezmoi/issues/new/choose) if this is +not working correctly. + +chezmoi provides a `completion` command and a `completion` template function +which return the shell completions for the given shell. These can be used +either as a one-off or as part of your dotfiles repo. The details of how to use +these depend on your shell. diff --git a/assets/chezmoi.io/docs/user-guide/include-files-from-elsewhere.md b/assets/chezmoi.io/docs/user-guide/include-files-from-elsewhere.md new file mode 100644 index 00000000000..21f464d9e7d --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/include-files-from-elsewhere.md @@ -0,0 +1,259 @@ +# Include dotfiles from elsewhere + +The sections below contain examples of how to use `.chezmoiexternal.toml` to +include files from external sources. For more details, check the [reference +manual](../reference/special-files-and-directories/chezmoiexternal-format.md) . + +## Include a subdirectory from a URL + +To include a subdirectory from another repository, e.g. [Oh My +Zsh](https://github.com/ohmyzsh/ohmyzsh), you cannot use git submodules because +chezmoi uses its own format for the source state and Oh My Zsh is not +distributed in this format. Instead, you can use the `.chezmoiexternal.$FORMAT` +to tell chezmoi to import dotfiles from an external source. + +For example, to import Oh My Zsh, the [zsh-syntax-highlighting +plugin](https://github.com/zsh-users/zsh-syntax-highlighting), and +[powerlevel10k](https://github.com/romkatv/powerlevel10k), put the following in +`~/.local/share/chezmoi/.chezmoiexternal.toml`: + +```toml title="~/.local/share/chezmoi/.chezmoiexternal.toml" +[".oh-my-zsh"] + type = "archive" + url = "https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz" + exact = true + stripComponents = 1 + refreshPeriod = "168h" +[".oh-my-zsh/custom/plugins/zsh-syntax-highlighting"] + type = "archive" + url = "https://github.com/zsh-users/zsh-syntax-highlighting/archive/master.tar.gz" + exact = true + stripComponents = 1 + refreshPeriod = "168h" +[".oh-my-zsh/custom/themes/powerlevel10k"] + type = "archive" + url = "https://github.com/romkatv/powerlevel10k/archive/v1.15.0.tar.gz" + exact = true + stripComponents = 1 +``` + +To apply the changes, run: + +```console +$ chezmoi apply +``` + +chezmoi will download the archives and unpack them as if they were part of the +source state. chezmoi caches downloaded archives locally to avoid +re-downloading them every time you run a chezmoi command, and will only +re-download them at most every `refreshPeriod` (default never). + +In the above example `refreshPeriod` is set to `168h` (one week) for +`.oh-my-zsh` and `.oh-my-zsh/custom/plugins/zsh-syntax-highlighting` because +the URL point to tarballs of the `master` branch, which changes over time. No +refresh period is set for `.oh-my-zsh/custom/themes/powerlevel10k` because the +URL points to the a tarball of a tagged version, which does not change over +time. To bump the version of powerlevel10k, change the version in the URL. + +To force a refresh the downloaded archives, use the `--refresh-externals` flag +to `chezmoi apply`: + +```console +$ chezmoi --refresh-externals apply +``` + +`--refresh-externals` can be shortened to `-R`: + +```console +$ chezmoi -R apply +``` + +When using Oh My Zsh, make sure you disable auto-updates by setting +`DISABLE_AUTO_UPDATE="true"` in `~/.zshrc`. Auto updates will cause the +`~/.oh-my-zsh` directory to drift out of sync with chezmoi's source state. To +update Oh My Zsh and its plugins, refresh the downloaded archives. + +!!! note + + If your external dependency target directory can contain cache files that are + added during normal use, chezmoi will report that files have changed on `chezmoi + apply`. To avoid this, add the cache directory to your + [`.chezmoiignore`](../reference/special-files-and-directories/chezmoiignore.md) + file. + + For example, Oh My Zsh may cache completions in `.oh-my-zsh/cache/completions/`, + which should be added to your `.chezmoiignore` file. + +## Include a subdirectory with selected files from a URL + +Use `include` pattern filters to include only selected files from an archive +URL. + +For example, to import just the required source files of the +[zsh-syntax-highlighting +plugin](https://github.com/zsh-users/zsh-syntax-highlighting) in the example +above, add in `include` filter to the `zsh-syntax-highlighting` section as shown +below: + +```toml title="~/.local/share/chezmoi/.chezmoiexternal.toml" +[".oh-my-zsh/custom/plugins/zsh-syntax-highlighting"] + type = "archive" + url = "https://github.com/zsh-users/zsh-syntax-highlighting/archive/master.tar.gz" + exact = true + stripComponents = 1 + refreshPeriod = "168h" + include = ["*/*.zsh", "*/.version", "*/.revision-hash", "*/highlighters/**"] +``` + +## Include a single file from a URL + +Including single files uses the same mechanism as including a subdirectory +above, except with the external type `file` instead of `archive`. For example, +to include +[`plug.vim`](https://github.com/junegunn/vim-plug/blob/master/plug.vim) from +[`github.com/junegunn/vim-plug`](https://github.com/junegunn/vim-plug) in +`~/.vim/autoload/plug.vim` put the following in +`~/.local/share/chezmoi/.chezmoiexternal.toml`: + +```toml title="~/.local/share/chezmoi/.chezmoiexternal.toml" +[".vim/autoload/plug.vim"] + type = "file" + url = "https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim" + refreshPeriod = "168h" +``` + +## Extract a single file from an archive + +You can extract a single file from an archive using the `archive-file` type in +`.chezmoiexternal.$FORMAT`, for example: + +```toml title="~/.local/share/chezmoi/.chezmoiexternal.toml" +{{ $ageVersion := "1.1.1" -}} +[".local/bin/age"] + type = "archive-file" + url = "https://github.com/FiloSottile/age/releases/download/v{{ $ageVersion }}/age-v{{ $ageVersion }}-{{ .chezmoi.os }}-{{ .chezmoi.arch }}.tar.gz" + path = "age/age" +``` + +This will extract the single archive member `age/age` from the given URL (which +is computed for the current OS and architecture) to the target +`./local/bin/age`. + +## Import archives + +It is occasionally useful to import entire archives of configuration into your +source state. The `import` command does this. For example, to import the latest +version [`github.com/ohmyzsh/ohmyzsh`](https://github.com/ohmyzsh/ohmyzsh) to +`~/.oh-my-zsh` run: + +```console +$ curl -s -L -o ${TMPDIR}/oh-my-zsh-master.tar.gz https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz +$ mkdir -p $(chezmoi source-path)/dot_oh-my-zsh +$ chezmoi import --strip-components 1 --destination ~/.oh-my-zsh ${TMPDIR}/oh-my-zsh-master.tar.gz +``` + +!!! note + + This only updates the source state. You will need to run: + + ```console + $ chezmoi apply + ``` + + to update your destination directory. + +## Handle tar archives in an unsupported compression format + +chezmoi natively understands tar archives. tar archives can be uncompressed or +compressed in the bzip2, gzip, xz, or zstd formats. + +If you have a tar archive in an unsupported compression format then you can use +a filter to decompress it. For example, before chezmoi natively supported the +zstd compression format, you could handle `.tar.zst` external archives with, for +example: + +```toml title="~/.local/share/chezmoi/.chezmoiexternal.toml" +[".Software/anki/2.1.54-qt6"] + type = "archive" + url = "https://github.com/ankitects/anki/releases/download/2.1.54/anki-2.1.54-linux-qt6.tar.zst" + filter.command = "zstd" + filter.args = ["-d"] + format = "tar" +``` + +Here `filter.command` and `filter.args` together tell chezmoi to filter the +downloaded data through `zstd -d`. The `format = "tar"` line tells chezmoi that +output of the filter is an uncompressed tar archive. + +## Include a subdirectory from a git repository + +You can configure chezmoi to keep a git repository up to date in a subdirectory +by using the external type `git-repo`, for example: + +```toml title="~/.local/share/chezmoi/.chezmoiexternal.toml" +[".vim/pack/alker0/chezmoi.vim"] + type = "git-repo" + url = "https://github.com/alker0/chezmoi.vim.git" + refreshPeriod = "168h" +``` + +If the directory does not exist then chezmoi will run `git clone` to clone it. +If the directory does exist then chezmoi will run `git pull` to pull the latest +changes, but not more often than every `refreshPeriod`. In the above example +the `refreshPeriod` is `168h` which is one week. The default `refreshPeriod` is +zero, which disables refreshes. You can force a refresh (i.e. force a `git +pull`) by passing the `--refresh-externals`/`-R` flag to `chezmoi apply`. + +!!! warning + + chezmoi's support for `git-repo` externals is limited to running `git + clone` and/or `git pull` in a directory. You must have a `git` binary + in your `$PATH`. + + Using a `git-repo` external delegates management of the + directory to git. chezmoi cannot manage any other files in that directory. + + The contents of `git-repo` externals will not be manifested in commands + like `chezmoi diff` or `chezmoi dump`, and will be listed by `chezmoi + unmanaged`. + +!!! hint + + If you need to manage extra files in a `git-repo` external, use an + `archive` external instead with the URL pointing to an archive of the git + repo's `master` or `main` branch. + +You can customize the arguments to `git clone` and `git pull` by setting the +`$DIR.clone.args` and `$DIR.pull.args` variables in `.chezmoiexternal.$FORMAT`, +for example: + +```toml title="~/.local/share/chezmoi/.chezmoiexternal.toml" +[".vim/pack/alker0/chezmoi.vim"] + type = "git-repo" + url = "https://github.com/alker0/chezmoi.vim.git" + refreshPeriod = "168h" + [".vim/pack/alker0/chezmoi.vim".pull] + args = ["--ff-only"] +``` + +## Use git submodules in your source directory + +!!! important + + If you use git submodules, then you should set the `external_` attribute on + the subdirectory containing the submodule. + +You can include git repos from elsewhere as git submodules in your source +directory. `chezmoi init` and `chezmoi update` are aware of git submodules and +will run git with the `--recurse-submodules` flag by default. + +chezmoi assumes that all files and directories in its source state are in +chezmoi's format, i.e. their filenames include attributes like `private_` and +`run_`. Most git submodules are not in chezmoi's format and so files like +`run_test.sh` will be interpreted by chezmoi as a `run_` script. To avoid +this problem, set the `external_` attribute on all subdirectories that contain +submodules. + +You can stop chezmoi from handling git submodules by passing the +`--recurse-submodules=false` flag or setting the `update.recurseSubmodules` +configuration variable to `false`. diff --git a/assets/chezmoi.io/docs/user-guide/machines/containers-and-vms.md b/assets/chezmoi.io/docs/user-guide/machines/containers-and-vms.md new file mode 100644 index 00000000000..63e2521e2d8 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/machines/containers-and-vms.md @@ -0,0 +1,74 @@ +# Containers and VMs + +You can use chezmoi to manage your dotfiles in [GitHub +Codespaces](https://docs.github.com/en/github/developing-online-with-codespaces/personalizing-codespaces-for-your-account), +[Visual Studio +Codespaces](https://code.visualstudio.com/docs/remote/codespaces), and [Visual +Studio Code Remote - +Containers](https://code.visualstudio.com/docs/remote/containers#_personalizing-with-dotfile-repositories). + +For a quick start, you can clone the [`chezmoi/dotfiles` +repository](https://github.com/chezmoi/dotfiles) which supports Codespaces out +of the box. + +The workflow is different to using chezmoi on a new machine, notably: + +* These systems will automatically clone your `dotfiles` repo to `~/dotfiles`, + so there is no need to clone your repo yourself. + +* The installation script must be non-interactive. + +* When running in a Codespace, the environment variable `CODESPACES` will be + set to `true`. You can read its value with the [`env` template + function](http://masterminds.github.io/sprig/os.html). + +First, if you are using a chezmoi configuration file template, ensure that it +is non-interactive when running in Codespaces, for example, +`.chezmoi.toml.tmpl` might contain: + +``` +{{- $codespaces:= env "CODESPACES" | not | not -}} +sourceDir = {{ .chezmoi.sourceDir | quote }} + +[data] + name = "Your name" + codespaces = {{ $codespaces }} +{{- if $codespaces }}{{/* Codespaces dotfiles setup is non-interactive, so set an email address */}} + email = "your@email.com" +{{- else }}{{/* Interactive setup, so prompt for an email address */}} + email = {{ promptString "email" | quote }} +{{- end }} +``` + +This sets the `codespaces` template variable, so you don't have to repeat `(env +"CODESPACES")` in your templates. It also sets the `sourceDir` configuration to +the `--source` argument passed in `chezmoi init`. + +Second, create an `install.sh` script that installs chezmoi and your dotfiles +and add it to `.chezmoiignore` and your dotfiles repo: + +```console +$ chezmoi generate install.sh > install.sh +$ chmod a+x install.sh +$ echo install.sh >> .chezmoiignore +$ git add install.sh .chezmoiignore +$ git commit -m "Add install.sh" +``` + +The generated script installs the latest version of chezmoi in `~/.local/bin` if +needed, and then `chezmoi init ...` invokes chezmoi to create its configuration +file and initialize your dotfiles. `--apply` tells chezmoi to apply the changes +immediately, and `--source=...` tells chezmoi where to find the cloned +`dotfiles` repo, which in this case is the same folder in which the script is +running from. + +Finally, modify any of your templates to use the `codespaces` variable if +needed. For example, to install `vim-gtk` on Linux but not in Codespaces, your +`run_once_install-packages.sh.tmpl` might contain: + +``` +{{- if (and (eq .chezmoi.os "linux") (not .codespaces)) -}} +#!/bin/sh +sudo apt install -y vim-gtk +{{- end -}} +``` diff --git a/assets/chezmoi.io/docs/user-guide/machines/general.md b/assets/chezmoi.io/docs/user-guide/machines/general.md new file mode 100644 index 00000000000..b15832b3a19 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/machines/general.md @@ -0,0 +1,58 @@ +# General + +## Determine whether the current machine is a laptop or desktop + +The following template sets the `$chassisType` variable to `"desktop"` or +`"laptop"` on macOS, Linux, and Windows. + +``` +{{- $chassisType := "desktop" }} +{{- if eq .chezmoi.os "darwin" }} +{{- if contains "MacBook" (output "sysctl" "-n" "hw.model") }} +{{- $chassisType = "laptop" }} +{{- else }} +{{- $chassisType = "desktop" }} +{{- end }} +{{- else if eq .chezmoi.os "linux" }} +{{- $chassisType = (output "hostnamectl" "--json=short" | mustFromJson).Chassis }} +{{- else if eq .chezmoi.os "windows" }} +{{- $chassisType = (output "powershell.exe" "-NoProfile" "-NonInteractive" "-Command" "if ((Get-CimInstance -Class Win32_Battery | Measure-Object).Count -gt 0) { Write-Output 'laptop' } else { Write-Output 'desktop' }") | trim }} +{{- end }} +``` + +## Determine how many CPU cores and threads the current machine has + +The following template sets the `$cpuCores` and `$cpuThreads` variables to the +number of CPU cores and threads on the current machine respectively on +macOS, Linux and Windows. + +``` +{{- $cpuCores := 1 }} +{{- $cpuThreads := 1 }} +{{- if eq .chezmoi.os "darwin" }} +{{- $cpuCores = (output "sysctl" "-n" "hw.physicalcpu_max") | trim | atoi }} +{{- $cpuThreads = (output "sysctl" "-n" "hw.logicalcpu_max") | trim | atoi }} +{{- else if eq .chezmoi.os "linux" }} +{{- $cpuCores = (output "sh" "-c" "lscpu --online --parse | grep --invert-match '^#' | sort --field-separator=',' --key='2,4' --unique | wc --lines") | trim | atoi }} +{{- $cpuThreads = (output "sh" "-c" "lscpu --online --parse | grep --invert-match '^#' | wc --lines") | trim | atoi }} +{{- else if eq .chezmoi.os "windows" }} +{{- $cpuCores = (output "powershell.exe" "-NoProfile" "-NonInteractive" "-Command" "(Get-CimInstance -ClassName 'Win32_Processor').NumberOfCores") | trim | atoi }} +{{- $cpuThreads = (output "powershell.exe" "-NoProfile" "-NonInteractive" "-Command" "(Get-CimInstance -ClassName 'Win32_Processor').NumberOfLogicalProcessors") | trim | atoi }} +{{- end }} +``` + +!!! example + + ``` title="~/.local/share/chezmoi/.chezmoi.toml.tmpl" + [data.cpu] + cores = {{ $cpuCores }} + threads = {{ $cpuThreads }} + ``` + + ``` title="~/.local/share/chezmoi/is_hyperthreaded.txt.tmpl" + {{- if gt .cpu.threads .cpu.cores -}} + Hyperthreaded! + {{- else -}} + Not hyperthreaded! + {{- end -}} + ``` diff --git a/assets/chezmoi.io/docs/user-guide/machines/linux.md b/assets/chezmoi.io/docs/user-guide/machines/linux.md new file mode 100644 index 00000000000..b860ed75e5a --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/machines/linux.md @@ -0,0 +1,51 @@ +# Linux + +## Combine operating system and Linux distribution conditionals + +There can be as much variation between Linux distributions as there is between +operating systems. Due to `text/template`'s eager evaluation of conditionals, +this means you often have to write templates with nested conditionals: + +``` +{{ if eq .chezmoi.os "darwin" }} +# macOS-specific code +{{ else if eq .chezmoi.os "linux" }} +{{ if eq .chezmoi.osRelease.id "debian" }} +# Debian-specific code +{{ else if eq .chezmoi.osRelease.id "fedora" }} +# Fedora-specific code +{{ end }} +{{ end }} +``` + +This can be simplified by combining the operating system and distribution into a +single custom template variable. Put the following in your configuration file +template: + +``` +{{- $osid := .chezmoi.os -}} +{{- if hasKey .chezmoi.osRelease "id" -}} +{{- $osid = printf "%s-%s" .chezmoi.os .chezmoi.osRelease.id -}} +{{- end -}} + +[data] + osid = {{ $osid | quote }} +``` + +This defines the `.osid` template variable to be `{{ .chezmoi.os }}` on +machines without an [`os-release` +file](https://www.freedesktop.org/software/systemd/man/os-release.html), or to +be `{{ .chezmoi.os }}-{{ .chezmoi.osRelease.id }}` on machines with an +`os-release` file. + +You can then simplify your conditionals to be: + +``` +{{ if eq .osid "darwin" }} +# macOS-specific code +{{ else if eq .osid "linux-debian" }} +# Debian-specific code +{{ else if eq .osid "linux-fedora" }} +# Fedora-specific code +{{ end }} +``` diff --git a/assets/chezmoi.io/docs/user-guide/machines/macos.md b/assets/chezmoi.io/docs/user-guide/machines/macos.md new file mode 100644 index 00000000000..7a39df3c5a6 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/machines/macos.md @@ -0,0 +1,36 @@ +# macOS + +## Use `brew bundle` to manage your brews and casks + +Homebrew's [`brew bundle` +subcommand](https://docs.brew.sh/Manpage#bundle-subcommand) allows you to +specify a list of brews and casks to be installed. You can integrate this with +chezmoi by creating a `run_once_` script. For example, create a file in your +source directory called `run_once_before_install-packages-darwin.sh.tmpl` +containing: + +``` +{{- if eq .chezmoi.os "darwin" -}} +#!/bin/bash + +brew bundle --no-lock --file=/dev/stdin < "${tempfile}" + # modify ${tempfile} + cat "${tempfile}" + ``` + +!!! note + + If the file does not exist then the standard input to the `modify_` script + will be empty and it is the script's responsibility to write a complete + file to the standard output. + +`modify_` scripts that contain the string `chezmoi:modify-template` are +executed as templates with the current contents of the file passed as +`.chezmoi.stdin` and the result of the template execution used as the new +contents of the file. + +!!! example + + To replace the string `old` with `new` in a file while leaving the rest of + the file unchanged, use the modify script: + + ``` + {{- /* chezmoi:modify-template */ -}} + {{- .chezmoi.stdin | replaceAllRegex "old" "new" }} + ``` + + To set individual values in JSON, JSONC, TOML, and YAML files you can use + the `setValueAtPath` template function, for example: + + ``` + {{- /* chezmoi:modify-template */ -}} + {{ fromJson .chezmoi.stdin | setValueAtPath "key.nestedKey" "value" | toPrettyJson }} + ``` + +!!! warning + + Modify templates must not have a `.tmpl` extension. + +Secondly, if only a small part of the file changes then consider using a +template to re-generate the full contents of the file from the current state. +For example, Kubernetes configurations include a current context that can be +substituted with: + +``` title="~/.local/share/chezmoi/dot_kube/config.tmpl" +current-context: {{ output "kubectl" "config" "current-context" | trim }} +``` + +!!! hint + + For managing ini files with a mix of settings and state (such as recently + used files or window positions), there is a third party tool called + `chezmoi_modify_manager` that builds upon `modify_` scripts. See + [related software](../links/related-software.md#githubcomvorpalbladechezmoi_modify_manager) + for more information. + + +## Manage a file's permissions, but not its contents + +chezmoi's `create_` attributes allows you to tell chezmoi to create a file if +it does not already exist. chezmoi, however, will apply any permission changes +from the `executable_`, `private_`, and `readonly_` attributes. This can be +used to control a file's permissions without altering its contents. + +For example, if you want to ensure that `~/.kube/config` always has permissions +600 then if you create an empty file called `dot_kube/private_config` in +your source state, chezmoi will ensure `~/.kube/config`'s permissions are 0600 +when you run `chezmoi apply` without changing its contents. + +This approach does have the downside that chezmoi will create the file if it +does not already exist. If you only want `chezmoi apply` to set a file's +permissions if it already exists and not create the file otherwise, you can use +a `run_` script. For example, create a file in your source state called +`run_set_kube_config_permissions.sh` containing: + +```bash +#!/bin/sh + +FILE="$HOME/.kube/config" +if [ -f "$FILE" ]; then + if [ "$(stat -c %a "$FILE")" != "600" ] ; then + chmod 600 "$FILE" + fi +fi +``` + +## Handle configuration files which are externally modified + +Some programs modify their configuration files. When you next run `chezmoi +apply`, any modifications made by the program will be lost. + +You can track changes to these files by replacing with a symlink back to a file +in your source directory, which is under version control. Here is a worked +example for VSCode's `settings.json` on Linux: + +Copy the configuration file to your source directory: + +```console +$ cp ~/.config/Code/User/settings.json $(chezmoi source-path) +``` + +Tell chezmoi to ignore this file: + +```console +$ echo settings.json >> $(chezmoi source-path)/.chezmoiignore +``` + +Tell chezmoi that `~/.config/Code/User/settings.json` should be a symlink to +the file in your source directory: + +```console +$ mkdir -p $(chezmoi source-path)/private_dot_config/private_Code/User +$ echo -n "{{ .chezmoi.sourceDir }}/settings.json" > $(chezmoi source-path)/private_dot_config/private_Code/User/symlink_settings.json.tmpl +``` + +The prefix `private_` is used because the `~/.config` and `~/.config/Code` +directories are private by default. + +Apply the changes: + +```console +$ chezmoi apply -v +``` + +Now, when the program modifies its configuration file it will modify the file +in the source state instead. + +## Populate `~/.ssh/authorized_keys` with your public SSH keys from GitHub + +chezmoi can retrieve your public SSH keys from GitHub, which can be useful for +populating your `~/.ssh/authorized_keys`. Put the following in your +`~/.local/share/chezmoi/dot_ssh/authorized_keys.tmpl`: + +``` +{{ range gitHubKeys "$GITHUB_USERNAME" -}} +{{ .Key }} +{{ end -}} +``` diff --git a/assets/chezmoi.io/docs/user-guide/manage-machine-to-machine-differences.md b/assets/chezmoi.io/docs/user-guide/manage-machine-to-machine-differences.md new file mode 100644 index 00000000000..91377552a5f --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/manage-machine-to-machine-differences.md @@ -0,0 +1,236 @@ +# Manage machine-to-machine differences + +## Use templates + +The primary goal of chezmoi is to manage configuration files across multiple +machines, for example your personal macOS laptop, your work Ubuntu desktop, and +your work Linux laptop. You will want to keep much configuration the same +across these, but also need machine-specific configurations for email +addresses, credentials, etc. chezmoi achieves this functionality by using +[`text/template`](https://pkg.go.dev/text/template) for the source state where +needed. + +For example, your home `~/.gitconfig` on your personal machine might look like: + +```toml title="~/.gitconfig" +[user] + email = "me@home.org" +``` + +Whereas at work it might be: + +```toml title="~/.gitconfig" +[user] + email = "firstname.lastname@company.com" +``` + +To handle this, on each machine create a configuration file called +`~/.config/chezmoi/chezmoi.toml` defining variables that might vary from +machine to machine. For example, for your home machine: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[data] + email = "me@home.org" +``` + +If you intend to store private data (e.g. access tokens) in +`~/.config/chezmoi/chezmoi.toml`, make sure it has permissions `0600`. + +If you prefer, you can use JSON, JSONC, or YAML for your configuration file. +Variable names must start with a letter and be followed by zero or more letters +or digits. + +Then, add `~/.gitconfig` to chezmoi using the `--template` flag to turn it +into a template: + +```console +$ chezmoi add --template ~/.gitconfig +``` + +You can then open the template (which will be saved in the file +`~/.local/share/chezmoi/dot_gitconfig.tmpl`): + +```console +$ chezmoi edit ~/.gitconfig +``` + +Edit the file so it looks something like: + +```toml title="~/.local/share/chezmoi/dot_gitconfig.tmpl" +[user] + email = {{ .email | quote }} +``` + +Templates are often used to capture machine-specific differences. For example, +in your `~/.local/share/chezmoi/dot_bashrc.tmpl` you might have: + +``` title="~/.local/share/chezmoi/dot_bashrc.tmpl" +# common config +export EDITOR=vi + +# machine-specific configuration +{{- if eq .chezmoi.hostname "work-laptop" }} +# this will only be included in ~/.bashrc on work-laptop +{{- end }} +``` + +For a full list of variables, run: + +```console +$ chezmoi data +``` + +For more advanced usage, you can use the full power of the +[`text/template`](https://pkg.go.dev/text/template) language. chezmoi includes +all of the text functions from [sprig](http://masterminds.github.io/sprig/) and +its own [functions for interacting with password +managers](../reference/templates/functions/index.md). + +Templates can be executed directly from the command line, without the need to +create a file on disk, with the `execute-template` command, for example: + +```console +$ chezmoi execute-template "{{ .chezmoi.os }}/{{ .chezmoi.arch }}" +``` + +This is useful when developing or [debugging +templates](../user-guide/templating.md#testing-templates). + +Some password managers allow you to store complete files. The files can be +retrieved with chezmoi's template functions. For example, if you have a file +stored in 1Password with the UUID `uuid` then you can retrieve it with the +template: + +``` +{{- onepasswordDocument "uuid" -}} +``` + +The `-`s inside the brackets remove any whitespace before or after the template +expression, which is useful if your editor has added any newlines. + +If, after executing the template, the file contents are empty, the target file +will be removed. This can be used to ensure that files are only present on +certain machines. If you want an empty file to be created anyway, you will need +to give it an `empty_` prefix. + +## Ignore files or a directory on different machines + +For coarser-grained control of files and entire directories managed on +different machines, or to exclude certain files completely, you can create +`.chezmoiignore` files in the source directory. These specify a list of +patterns that chezmoi should ignore, and are interpreted as templates. An +example `.chezmoiignore` file might look like: + +``` title="~/.local/share/chezmoi/.chezmoiignore" +README.md +{{- if ne .chezmoi.hostname "work-laptop" }} +.work # only manage .work on work-laptop +{{- end }} +``` + +The use of `ne` (not equal) is deliberate. What we want to achieve is "only +install `.work` if hostname is `work-laptop`" but chezmoi installs everything +by default, so we have to turn the logic around and instead write "ignore +`.work` unless the hostname is `work-laptop`". + +Patterns can be excluded by starting the line with a `!`, for example: + +``` title="~/.local/share/chezmoi/.chezmoiignore" +dir/f* +!dir/foo +``` + +will ignore all files beginning with an `f` in `dir` except for `dir/foo`. + +You can see what files chezmoi ignores with the command + +```console +$ chezmoi ignored +``` + +## Handle different file locations on different systems with the same contents + +If you want to have the same file contents in different locations on different +systems, but maintain only a single file in your source state, you can use a +shared template. + +Create the common file in the `.chezmoitemplates` directory in the source state. +For example, create `.chezmoitemplates/file.conf`. The contents of this file are +available in templates with the `template $NAME .` function where `$NAME` is the +name of the file (`.` passes the current data to the template code in +`file.conf`; see [`template` +action](https://pkg.go.dev/text/template#hdr-Actions) for details). + +Then create files for each system, for example `Library/Application +Support/App/file.conf.tmpl` for macOS and `dot_config/app/file.conf.tmpl` for +Linux. Both template files should contain `{{- template "file.conf" . -}}`. + +Finally, tell chezmoi to ignore files where they are not needed by adding lines +to your `.chezmoiignore` file, for example: + +``` title="~/.local/share/chezmoi/.chezmoiignore" +{{ if ne .chezmoi.os "darwin" }} +Library/Application Support/App/file.conf +{{ end }} +{{ if ne .chezmoi.os "linux" }} +.config/app/file.conf +{{ end }} +``` + +## Use completely different dotfiles on different machines + +chezmoi's template functionality allows you to change a file's contents based +on any variable. For example, if you want `~/.bashrc` to be different on Linux +and macOS you would create a file in the source state called `dot_bashrc.tmpl` +containing: + +``` title="~/.local/share/chezmoi/dot_bashrc.tmpl" +{{ if eq .chezmoi.os "darwin" -}} +# macOS .bashrc contents +{{ else if eq .chezmoi.os "linux" -}} +# Linux .bashrc contents +{{ end -}} +``` + +However, if the differences between the two versions are so large that you'd +prefer to use completely separate files in the source state, you can achieve +this with the `include` template function. + +Create the following files: + +```bash title="~/.local/share/chezmoi/.bashrc_darwin" +# macOS .bashrc contents +``` + +```bash title="~/.local/share/chezmoi/.bashrc_linux" +# Linux .bashrc contents +``` + +``` title="~/.local/share/chezmoi/dot_bashrc.tmpl" +{{- if eq .chezmoi.os "darwin" -}} +{{- include ".bashrc_darwin" -}} +{{- else if eq .chezmoi.os "linux" -}} +{{- include ".bashrc_linux" -}} +{{- end -}} +``` + +This will cause `~/.bashrc` to contain `~/.local/share/chezmoi/.bashrc_darwin` +on macOS and `~/.local/share/chezmoi/.bashrc_linux` on Linux. + +If you want to use templates within your templates, then, instead, create: + +```bash title="~/.local/share/chezmoi/.chezmoitemplates/bashrc_darwin.tmpl" +# macOS .bashrc template contents +``` + +```bash title="~/.local/share/chezmoi/.chezmoitemplates/bashrc_linux.tmpl" +# Linux .bashrc template contents +``` + +``` title="~/.local/share/chezmoi/dot_bashrc.tmpl" +{{- if eq .chezmoi.os "darwin" -}} +{{- template "bashrc_darwin.tmpl" . -}} +{{- else if eq .chezmoi.os "linux" -}} +{{- template "bashrc_linux.tmpl" . -}} +{{- end -}} +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/1password.md b/assets/chezmoi.io/docs/user-guide/password-managers/1password.md new file mode 100644 index 00000000000..915a6fb9fc3 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/1password.md @@ -0,0 +1,218 @@ +# 1Password + +chezmoi includes support for [1Password](https://1password.com/) using the +[1Password CLI](https://support.1password.com/command-line-getting-started/) to +expose data as a template function. + +Log in and get a session using: + +```console +$ op account add --address $SUBDOMAIN.1password.com --email $EMAIL +$ eval $(op signin --account $SUBDOMAIN) +``` + +This is not necessary if you are using biometric authentication. + +The output of `op read $URL` is available as the `onepasswordRead` template +function, for example: + +``` +{{ onepasswordRead "op://app-prod/db/password" }} +``` + +returns the output of + +```console +$ op read op://app-prod/db/password +``` + +Documents can be retrieved with: + +``` +{{- onepasswordDocument "$UUID" -}} +``` + +The output of `op item get $UUID --format json` is available as the +`onepassword` template function. chezmoi parses the JSON output and returns it +as structured data. For example, if the output is: + +```json +{ + "id": "$UUID", + "title": "$TITLE", + "version": 2, + "vault": { + "id": "$vaultUUID" + }, + "category": "LOGIN", + "last_edited_by": "$userUUID", + "created_at": "2010-08-23T13:18:43Z", + "updated_at": "2014-07-20T04:40:11Z", + "fields": [ + { + "id": "username", + "type": "STRING", + "purpose": "USERNAME", + "label": "username", + "value": "$USERNAME" + }, + { + "id": "password", + "type": "CONCEALED", + "purpose": "PASSWORD", + "label": "password", + "value": "$PASSWORD", + "password_details": { + "strength": "FANTASTIC", + "history": [] + } + } + ], + "urls": [ + { + "primary": true, + "href": "$URL" + } + ] +} +``` + +Then you can access the password field with the syntax + +``` +{{ (index (onepassword "$UUID").fields 1).value }} +``` + +or: + +``` +{{ range (onepassword "$UUID").fields -}} +{{ if and (eq .label "password") (eq .purpose "PASSWORD") -}} +{{ .value -}} +{{ end -}} +{{ end }} +``` + +`onepasswordDetailsFields` returns a reworked version of the structure that +allows the fields to be queried by key: + +```json +{ + "password": { + "id": "password", + "label": "password", + "password_details": { + "history": [], + "strength": "FANTASTIC" + }, + "purpose": "PASSWORD", + "type": "CONCEALED", + "value": "$PASSWORD" + }, + "username": { + "id": "username", + "label": "username", + "purpose": "USERNAME", + "type": "STRING", + "value": "$USERNAME" + } +} +``` + +``` +{{- (onepasswordDetailsFields "$UUID").password.value }} +``` + +Additional fields may be obtained with `onepasswordItemFields`; not all objects +in 1Password have item fields. This can be tested with: + +```console +$ chezmoi execute-template "{{ onepasswordItemFields \"$UUID\" | toJson }}" | \ + jq . +``` + +## Sign-in prompt + +chezmoi will verify the availability and validity of a session token in the +current environment. If it is missing or expired, you will be interactively +prompted to sign-in again. + +In the past chezmoi used to exit with an error when no valid session was +available. If you'd like to restore this behavior, set the `onepassword.prompt` +configuration variable to `false`, for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[onepassword] + prompt = false +``` + +!!! danger + + Do not use `prompt` on shared machines. A session token verified or acquired + interactively will be passed to the 1Password CLI through a command line + parameter, which is visible to other users of the same system. + +## Secrets Automation + +chezmoi has experimental support for secrets automation with [1Password +Connect](https://developer.1password.com/docs/connect/) and [1Password Service +Accounts](https://developer.1password.com/docs/service-accounts). These might be +used on restricted machines where you cannot or do not wish to install a full +1Password desktop application. + +When these features are used, the behavior of the 1Password CLI changes, so +chezmoi requires explicit configuration for either connect or service account +modes using the `onepassword.mode` configuration option. The default, if not +specified, is `account`: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[onepassword] + mode = "account" +``` + +In `account` mode, chezmoi will stop with an error if the environment variable +`OP_SERVICE_ACCOUNT_TOKEN` is set, or if both environment variables +`OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` are set. + +!!! info + + Both 1Password Connect and Service Accounts prevent the CLI from working + with multiple accounts. If you need access to secrets from more than one + 1Password account, do not use these features with chezmoi. + +### 1Password Connect + +Once 1Password Connect is +[configured](https://developer.1password.com/docs/connect/connect-cli#requirements), +and `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` are properly set, set +`onepassword.mode` to `connect`. + +```toml title="~/.config/chezmoi/chezmoi.toml" +[onepassword] + mode = "connect" +``` + +In `connect` mode: + +- the `onepasswordDocument` template function is not available, +- `account` parameters are not allowed in 1Password template functions, +- chezmoi will stop with an error if one or both of `OP_CONNECT_HOST` and + `OP_CONNECT_TOKEN` are unset, or if `OP_SERVICE_ACCOUNT_TOKEN` is set. + +### 1Password Service Accounts + +Once a 1Password service account has been +[created](https://developer.1password.com/docs/service-accounts/use-with-1password-cli/#requirements) +and `OP_SERVICE_ACCOUNT_TOKEN` is properly set, set `onepassword.mode` to +`service`. + +```toml title="~/.config/chezmoi/chezmoi.toml" +[onepassword] + mode = "service" +``` + +In `service` mode: + +- `account` parameters are not allowed in 1Password template functions, +- chezmoi will stop with an error if `OP_SERVICE_ACCOUNT_TOKEN` is unset, or if + both of `OP_CONNECT_HOST` and `OP_CONNECT_TOKEN` are set. diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/aws-secrets-manager.md b/assets/chezmoi.io/docs/user-guide/password-managers/aws-secrets-manager.md new file mode 100644 index 00000000000..4877bf77c50 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/aws-secrets-manager.md @@ -0,0 +1,29 @@ +# AWS Secrets Manager + +chezmoi includes support for [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). + +Structured data can be retrieved with the `awsSecretsManager` template function, for +example: + +``` +exampleUsername = {{ (awsSecretsManager "my-secret-name").username }} +examplePassword = {{ (awsSecretsManager "my-secret-name").password }} +``` + +For retrieving unstructured data, the `awsSecretsManagerRaw` template function can be used. +For example: + +``` +exampleSecretString = {{ awsSecretsManagerRaw "my-secret-string" }} +``` + +The AWS shared profile name and region can be specified in chezmoi's config file with +`awsSecretsManager.profile` and `awsSecretsManager.region` respectively. By default, these +values will be picked up from the standard environment variables and config files used +by the standard AWS tooling. + +```toml title="~/.config/chezmoi/chezmoi.toml" +[awsSecretsManager] + profile = myWorkProfile + region = us-east-2 +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/azure-key-vault.md b/assets/chezmoi.io/docs/user-guide/password-managers/azure-key-vault.md new file mode 100644 index 00000000000..798d63b0d25 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/azure-key-vault.md @@ -0,0 +1,50 @@ +# Azure Key Vault + +chezmoi includes support for [Azure Key Vault secrets](https://learn.microsoft.com/en-us/azure/key-vault/secrets/about-secrets). + +A default Azure Key Vault name can be set in `~/.config/chezmoi/chezmoi.toml` with +`azureKeyVault.defaultVault`. + +Ensure [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) is installed and +[log in](https://learn.microsoft.com/en-us/azure/developer/go/azure-sdk-authentication?tabs=bash#azureCLI). +The logged in user must have the `Key Vault Secrets User` RBAC role on the Azure Key Vault resource. + +Alternatively, use alternate [authentication options](https://learn.microsoft.com/en-us/azure/developer/go/azure-sdk-authentication?tabs=bash#2-authenticate-with-azure). + + +```toml title="~/.config/chezmoi/chezmoi.toml" +[azureKeyVault] + defaultVault = "contoso-vault2" +``` + +A secret value can be retrieved with the `azureKeyVault` template function. + +Retrieve the secret `my-secret-name` from the default configured vault. + +``` +exampleSecret = {{ azureKeyVault "my-secret-name" }} +``` + +Retrieve the secret `my-secret-name` from the vault named `contoso-vault2`. + +``` +exampleSecret = {{ azureKeyVault "my-secret-name" "contoso-vault2" }} +``` + +It is also possible to define an alias in the configuration file for an +additional vault. + +```toml title="~/.config/chezmoi/chezmoi.toml" +[data] + vault42 = "contoso-vault42" + +[azureKeyVault] + defaultVault = "contoso-vault2" +``` + +Retrieve the secret `my-secret-name` from the vault named `contoso-vault42` +through the alias. + +``` +exampleSecret = {{ azureKeyVault "my-secret-name" .vault42 }} +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/bitwarden.md b/assets/chezmoi.io/docs/user-guide/password-managers/bitwarden.md new file mode 100644 index 00000000000..b14a9b32bb9 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/bitwarden.md @@ -0,0 +1,91 @@ +# Bitwarden + +chezmoi includes support for [Bitwarden](https://bitwarden.com/) using the +[Bitwarden CLI](https://bitwarden.com/help/cli) (`bw`), [Bitwarden +Secrets CLI](https://bitwarden.com/help/secrets-manager-cli/) (`bws`), and +[`rbw`](https://github.com/doy/rbw) commands to expose data as a template +function. + +## Bitwarden CLI + +Log in to Bitwarden using a normal method + +```console +$ bw login $BITWARDEN_EMAIL # or +$ bw login --apikey # or +$ bw login --sso +``` + +If required, unlock your Bitwarden vault (API key and SSO logins always require +an explicit unlock step): + +```console +$ bw unlock +``` + +Set the `BW_SESSION` environment variable, as instructed. + +!!! tip "Bitwarden Session One-liner" + + The `BW_SESSION` value can be set directly. The exact combination differs + based on whether you are currently logged into Bitwarden and how you log + into Bitwarden. + + ```console + $ # You are already logged in with any method + $ export BW_SESSION=$(bw unlock --raw) + $ # You are not logged in and log in with an email + $ export BW_SESSION=$(bw login $BITWARDEN_EMAIL --raw) + $ # You are not logged in and login with SSO or API key + $ export BW_SESSION=$(bw login --sso && bw unlock --raw) + ``` + +The structured data from `bw get` is available as the `bitwarden` template +function in your config files, for example: + +``` +username = {{ (bitwarden "item" "example.com").login.username }} +password = {{ (bitwarden "item" "example.com").login.password }} +``` + +Custom fields can be accessed with the `bitwardenFields` template function. For +example, if you have a custom field named `token` you can retrieve its value +with: + +``` +{{ (bitwardenFields "item" "example.com").token.value }} +``` + +Attachments can be accessed with the `bitwardenAttachment` and +`bitwardenAttachmentByRef` template function. For example, if you have an +attachment named `id_rsa`, you can retrieve its value with: + +``` +{{ bitwardenAttachment "id_rsa" "bf22e4b4-ae4a-4d1c-8c98-ac620004b628" }} +``` + +or + +``` +{{ bitwardenAttachmentByRef "id_rsa" "item" "example.com" }} +``` + +## Bitwarden Secrets CLI + +Generate an [access token](https://bitwarden.com/help/access-tokens/) for a +specific [service account](https://bitwarden.com/help/service-accounts/). + +Either set the `BWS_ACCESS_TOKEN` environment variable or store the access token +in a template variable, e.g. + +```toml title="~/.config/chezmoi/chezmoi.toml" +[data] + accessToken = "0.48c78342-1635-48a6-accd-afbe01336365.C0tMmQqHnAp1h0gL8bngprlPOYutt0:B3h5D+YgLvFiQhWkIq6Bow==" +``` + +You can then retrieve secrets using the `bitwardenSecrets` template function, for +example: + +``` +{{ (bitwardenSecrets "be8e0ad8-d545-4017-a55a-b02f014d4158" .accessToken).value }} +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/custom.md b/assets/chezmoi.io/docs/user-guide/password-managers/custom.md new file mode 100644 index 00000000000..225f5ec16b1 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/custom.md @@ -0,0 +1,21 @@ +# Custom + +You can use any command line tool that outputs secrets either as a string or in +JSON format. Choose the binary by setting `secret.command` in your +configuration file. You can then invoke this command with the `secret` and +`secretJSON` template functions which return the raw output and JSON-decoded +output respectively. All of the above secret managers can be supported in this +way: + +| Secret Manager | `secret.command` | Template skeleton | +| ----------------- | ---------------- | ---------------------------------------------------------------- | +| 1Password | `op` | `{{ secretJSON "get" "item" "$ID" }}` | +| Bitwarden | `bw` | `{{ secretJSON "get" "$ID" }}` | +| Doppler | `doppler` | `{{ secretJSON "secrets" "download" "--json" "--no-file" }}` | +| HashiCorp Vault | `vault` | `{{ secretJSON "kv" "get" "-format=json" "$ID" }}` | +| HCP Vault Secrets | `vlt` | `{{ secret "secrets" "get" "--plaintext" "$ID" }}` | +| LastPass | `lpass` | `{{ secretJSON "show" "--json" "$ID" }}` | +| KeePassXC | `keepassxc-cli` | Not possible (interactive command only) | +| Keeper | `keeper` | `{{ secretJSON "get" "--format=json" "$ID" }}` | +| pass | `pass` | `{{ secret "show" "$ID" }}` | +| passhole | `ph` | `{{ secret "$ID" "password" }}` | diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/dashlane.md b/assets/chezmoi.io/docs/user-guide/password-managers/dashlane.md new file mode 100644 index 00000000000..b96dbd27482 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/dashlane.md @@ -0,0 +1,17 @@ +# Dashlane + +chezmoi includes support for [Dashlane](https://dashlane.com). + +Structured data can be retrieved with the `dashlanePassword` template function, +for example: + +``` +examplePassword = {{ (index (dashlanePassword "filter") 0).password }} +``` + +Secure notes can be retrieved with the `dashlaneNote` template function, +for example: + +``` +exampleNote = {{ dashlaneNote "filter" }} +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/doppler.md b/assets/chezmoi.io/docs/user-guide/password-managers/doppler.md new file mode 100644 index 00000000000..c2c97fbfef3 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/doppler.md @@ -0,0 +1,53 @@ +# Doppler + +chezmoi includes support for [Doppler](https://www.doppler.com) using the `doppler` +CLI to expose data through the `doppler` and `dopplerProjectJson` +template functions. + +Log in using: + +```console +$ doppler login +``` + +It is now possible to interact with the `doppler` CLI in two different, but similar, ways. +Both make use of the command `doppler secrets download --json --no-file` behind the scenes +but present a different experience. + +The `doppler` function is used in the following way: +``` +{{ doppler "SECRET_NAME" "project name" "config" }} +``` + +All secrets from the specified project/config combination are cached for subsequent access and +will not requery the `doppler` CLI for another secret in the same project/config. +This caching mechanism enhances performance and reduces unnecessary CLI calls. + +The `dopplerProjectJson` presents the secrets as `json` structured data and is used in the following +way: +``` +{{ (dopplerProjectJson "project" "config").PASSWORD }} +``` + +Additionally one can set the default values for the project and +config (aka environment) in your config file, for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[doppler] + project = "my-project" + config = "dev" +``` +With these default values, you can omit them in the call to both `doppler` and `dopplerProjectJson`, +for example: +``` +{{ doppler "SECRET_NAME" }} +{{ dopplerProjectJson.SECRET_NAME }} +``` + +It is important to note that neither of the above parse any individual secret as `json`. +This can be achieved by using the `fromJson` function, for example: +``` +{{ (doppler "SECRET_NAME" | fromJson).created_by.email_address }} +{{ (dopplerProjectJson.SECRET_NAME | fromJson).created_by.email_address }} +``` +Obviously the secret would have to be saved in `json` format for this to work as expected. diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/ejson.md b/assets/chezmoi.io/docs/user-guide/password-managers/ejson.md new file mode 100644 index 00000000000..f396e53eb45 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/ejson.md @@ -0,0 +1,18 @@ +# ejson + +chezmoi includes support for [ejson](https://github.com/Shopify/ejson). + +Structured data can be retrieved with the `ejsonDecrypt` template function, +for example: + +``` +examplePassword = {{ (ejsonDecrypt "my-secrets.ejson").password }} +``` + +If you want to specify the private key to use for the decryption, +structured data can be retrieved with the `ejsonDecryptWithKey` template +function, for example: + +``` +examplePassword = {{ (ejsonDecryptWithKey "my-secrets.ejson" "top-secret-key").password }} +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/gopass.md b/assets/chezmoi.io/docs/user-guide/password-managers/gopass.md new file mode 100644 index 00000000000..125c1bc6e63 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/gopass.md @@ -0,0 +1,11 @@ +# gopass + +chezmoi includes support for [gopass](https://www.gopass.pw/) using the gopass +CLI. + +The first line of the output of `gopass show $PASS_NAME` is available as the +`gopass` template function, for example: + +``` +{{ gopass "$PASS_NAME" }} +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/hcp-vault-secrets.md b/assets/chezmoi.io/docs/user-guide/password-managers/hcp-vault-secrets.md new file mode 100644 index 00000000000..a2e4f1cc77f --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/hcp-vault-secrets.md @@ -0,0 +1,43 @@ +# HCP Vault Secrets + +chezmoi includes support for [HCP Vault +Secrets](https://developer.hashicorp.com/hcp/docs/vault-secrets) using the `vlt` +CLI to expose data through the `hcpVaultSecret` and `hcpVaultSecretJson` +template functions. + +Log in using: + +```console +$ vlt login +``` + +The output of the `vlt secrets get --plaintext $SECRET_NAME` is available as the +`hcpVaultSecret` function, for example: + +``` +{{ hcpVaultSecret "secret_name" "application_name" "project_id" "organization_id" }} +``` + +You can set the default values for the application name, project ID, and +organization ID in your config file, for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[hcpVaultSecrets] + organizationId = "bf479eab-a292-4b46-92df-e22f5c47eadc" + projectId = "5907a2fa-d26a-462a-8705-74dfe967e87d" + applicationName = "my-application" +``` + +With these default values, you can omit them in the call to `hcpVaultSecret`, for example: + +``` +{{ hcpVaultSecret "secret_name" }} +{{ hcpVaultSecret "other_secret_name" "other_application_name" }} +``` + +Structured data from `vlt secrets get --format=json $SECRET_NAME` is available +as the `hcpVaultSecretJson` template function, for example: + +``` +{{ (hcpVaultSecretJson "secret_name").created_by.email }} +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/index.md b/assets/chezmoi.io/docs/user-guide/password-managers/index.md new file mode 100644 index 00000000000..a756ba8a73b --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/index.md @@ -0,0 +1,6 @@ +# Password manager integration + +Template functions allow you to retrieve secrets from many popular password +managers. Using a password manager allows you to keep all your secrets in one +place, make your dotfiles repo public, and synchronize changes to secrets +across multiple machines. diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/keepassxc.md b/assets/chezmoi.io/docs/user-guide/password-managers/keepassxc.md new file mode 100644 index 00000000000..2dc0ecb5932 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/keepassxc.md @@ -0,0 +1,50 @@ +# KeePassXC + +chezmoi includes support for [KeePassXC](https://keepassxc.org) using the +KeePassXC CLI (`keepassxc-cli`) to expose data as a template function. + +Provide the path to your KeePassXC database in your configuration file: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[keepassxc] + database = "/home/user/Passwords.kdbx" +``` + +The structured data from `keepassxc-cli show $database` is available as the +`keepassxc` template function in your config files, for example: + +``` +username = {{ (keepassxc "example.com").UserName }} +password = {{ (keepassxc "example.com").Password }} +``` + +Additional attributes are available through the `keepassxcAttribute` function. +For example, if you have an entry called `SSH Key` with an additional attribute +called `private-key`, its value is available as: + +``` +{{ keepassxcAttribute "SSH Key" "private-key" }} +``` + +## Non-password-protected databases + +If your database is not password protected, add `--no-password` to +`keepassxc.args` and `keepassxc.prompt = false`: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[keepassxc] + args = ["--no-password"] + prompt = false +``` + +## YubiKey support + +chezmoi includes an experimental mode to support using KeePassXC with YubiKeys. +Set `keepassxc.mode` to `open` and `keepassxc.args` to the arguments required to +set your YubiKey, for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[keepassxc] + args = ["--yubikey", "1:7370001"] + mode = "open" +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/keeper.md b/assets/chezmoi.io/docs/user-guide/password-managers/keeper.md new file mode 100644 index 00000000000..ccd1689c997 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/keeper.md @@ -0,0 +1,35 @@ +# Keeper + +chezmoi includes support for [Keeper](https://www.keepersecurity.com/) using the +[Commander CLI](https://docs.keeper.io/secrets-manager/commander-cli) to expose +data as a template function. + +Create a persistent login session as [described in the Command CLI +documentation](https://docs.keeper.io/secrets-manager/commander-cli/using-commander/logging-in#persistent-login-sessions). + +Passwords can be retrieved with the `keeperFindPassword` template function, for +example: + +``` +examplePasswordFromPath = {{ keeperFindPassword "$PATH" }} +examplePasswordFromUid = {{ keeperFindPassword "$UID" }} +``` + +For retrieving more complex data, use the `keeper` template function with a UID +to retrieve structured data from [`keeper +get`](https://docs.keeper.io/secrets-manager/commander-cli/using-commander/command-reference/record-commands#get-command) +or the `keeperDataFields` template function which restructures the output of +`keeper get` in to a more convenient form, for example: + +``` +keeperDataTitle = {{ (keeper "$UID").data.title }} +examplePassword = {{ index (keeperDataFields "$UID").password 0 }} +``` + +Extra arguments can be passed to the Keeper CLI command by setting the +`keeper.args` variable in chezmoi's config file, for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[keeper] + args = ["--config", "/path/to/config.json"] +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/keychain-and-windows-credentials-manager.md b/assets/chezmoi.io/docs/user-guide/password-managers/keychain-and-windows-credentials-manager.md new file mode 100644 index 00000000000..d41ae8f45fd --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/keychain-and-windows-credentials-manager.md @@ -0,0 +1,36 @@ +# Keychain and Windows Credentials Manager + +chezmoi includes support for Keychain (on macOS), GNOME Keyring (on Linux and +FreeBSD), and Windows Credentials Manager (on Windows) via the +[`zalando/go-keyring`](https://github.com/zalando/go-keyring) library. + +Set values with: + +```console +$ chezmoi secret keyring set --service=$SERVICE --user=$USER +Value: xxxxxxxx +``` + +The value can then be used in templates using the `keyring` function which +takes the service and user as arguments. + +For example, save a GitHub access token in keyring with: + +```console +$ chezmoi secret keyring set --service=github --user=$GITHUB_USERNAME +Value: xxxxxxxx +``` + +and then include it in your `~/.gitconfig` file with: + +``` +[github] + user = {{ .github.user | quote }} + token = {{ keyring "github" .github.user | quote }} +``` + +You can query the keyring from the command line: + +```console +$ chezmoi secret keyring get --service=github --user=$GITHUB_USERNAME +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/lastpass.md b/assets/chezmoi.io/docs/user-guide/password-managers/lastpass.md new file mode 100644 index 00000000000..b33cb19020d --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/lastpass.md @@ -0,0 +1,48 @@ +# LastPass + +chezmoi includes support for [LastPass](https://lastpass.com/) using the +[LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html) to expose +data as a template function. + +Log in to LastPass using: + +```console +$ lpass login $LASTPASS_USERNAME +``` + +Check that `lpass` is working correctly by showing password data: + +``` console +$ lpass show --json $LASTPASS_ENTRY_ID +``` + +where `$LASTPASS_ENTRY_ID` is a [LastPass Entry +Specification](https://lastpass.github.io/lastpass-cli/lpass.1.html#_entry_specification). + +The structured data from `lpass show --json id` is available as the `lastpass` +template function. The value will be an array of objects. You can use the +`index` function and `.Field` syntax of the `text/template` language to extract +the field you want. For example, to extract the `password` field from first the +"GitHub" entry, use: + +``` +githubPassword = {{ (index (lastpass "GitHub") 0).password | quote }} +``` + +chezmoi automatically parses the `note` value of the LastPass entry as +colon-separated key-value pairs, so, for example, you can extract a private SSH +key like this: + +``` +{{ (index (lastpass "SSH") 0).note.privateKey }} +``` + +Keys in the `note` section written as `CamelCase Words` are converted to +`camelCaseWords`. + +If the `note` value does not contain colon-separated key-value pairs, then you +can use `lastpassRaw` to get its raw value, for example: + +``` +{{ (index (lastpassRaw "SSH Private Key") 0).note }} +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/pass.md b/assets/chezmoi.io/docs/user-guide/password-managers/pass.md new file mode 100644 index 00000000000..a54d5251311 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/pass.md @@ -0,0 +1,11 @@ +# pass + +chezmoi includes support for [pass](https://www.passwordstore.org/) using the +pass CLI. + +The first line of the output of `pass show $PASS_NAME` is available as the +`pass` template function, for example: + +``` +{{ pass "$PASS_NAME" }} +``` diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/passhole.md b/assets/chezmoi.io/docs/user-guide/password-managers/passhole.md new file mode 100644 index 00000000000..f89d13eb01c --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/passhole.md @@ -0,0 +1,5 @@ +# Passhole + +chezmoi includes support for [KeePass](https://keepass.info/) using the +[passhole CLI](https://github.com/Evidlo/passhole) (`ph`) to expose data as a +template function. diff --git a/assets/chezmoi.io/docs/user-guide/password-managers/vault.md b/assets/chezmoi.io/docs/user-guide/password-managers/vault.md new file mode 100644 index 00000000000..2ade7492548 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/password-managers/vault.md @@ -0,0 +1,21 @@ +# Vault + +chezmoi includes support for [Vault](https://www.vaultproject.io/) using the +[Vault CLI](https://www.vaultproject.io/docs/commands/) to expose data as a +template function. + +The vault CLI needs to be correctly configured on your machine, e.g. the +`VAULT_ADDR` and `VAULT_TOKEN` environment variables must be set correctly. +Verify that this is the case by running: + +```console +$ vault kv get -format=json $KEY +``` + +The structured data from `vault kv get -format=json` is available as the +`vault` template function. You can use the `.Field` syntax of the +`text/template` language to extract the data you want. For example: + +``` +{{ (vault "$KEY").data.data.password }} +``` diff --git a/assets/chezmoi.io/docs/user-guide/setup.md b/assets/chezmoi.io/docs/user-guide/setup.md new file mode 100644 index 00000000000..445aa86efa8 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/setup.md @@ -0,0 +1,146 @@ +# Setup + +## Understand chezmoi's files and directories + +chezmoi generates your dotfiles for your local machine. It combines two main +sources of data: + +The *source directory*, `~/.local/share/chezmoi`, is common to all your +machines, and is a clone of your dotfiles repo. Each file that chezmoi manages +has a corresponding file in the source directory. + +The *config file*, typically `~/.config/chezmoi/chezmoi.toml` (although you can +use JSON or YAML if you prefer), is specific to the local machine. + +Files whose contents are the same on all of your machines are copied verbatim +from the source directory. Files which vary from machine to machine are executed +as templates, typically using data from the local machine's config file to tune +the final contents specific to the local machine. + +## Use a hosted repo to manage your dotfiles across multiple machines + +chezmoi relies on your version control system and hosted repo to share changes +across multiple machines. You should create a repo on the source code repository +of your choice (e.g. [Bitbucket](https://bitbucket.org), +[GitHub](https://github.com/), or [GitLab](https://gitlab.com), many people call +their repo `dotfiles`) and push the repo in the source directory here. For +example: + +```console +$ chezmoi cd +$ git remote add origin https://github.com/$GITHUB_USERNAME/dotfiles.git +$ git push -u origin main +$ exit +``` + +On another machine you can checkout this repo: + +```console +$ chezmoi init https://github.com/$GITHUB_USERNAME/dotfiles.git +``` + +You can then see what would be changed: + +```console +$ chezmoi diff +``` + +If you're happy with the changes then apply them: + +```console +$ chezmoi apply +``` + +The above commands can be combined into a single init, checkout, and apply: + +```console +$ chezmoi init --apply --verbose https://github.com/$GITHUB_USERNAME/dotfiles.git +``` + +These commands are summarized this sequence diagram: + +```mermaid +sequenceDiagram + participant H as home directory + participant W as working copy + participant L as local repo + participant R as remote repo + R->>W: chezmoi init <repo> + W-->>H: chezmoi diff + W->>H: chezmoi apply + R->>H: chezmoi init --apply <repo> +``` + +## Use a private repo to store your dotfiles + +chezmoi supports storing your dotfiles in both public and private repos. + +chezmoi is designed so that your dotfiles repo can be public by making it easy +for you to store your secrets either in your password manager, in encrypted +files, or in private configuration files. Your dotfiles repo can still be +private, if you choose. + +If you use a private repo for your dotfiles then you will typically need to +enter your credentials (e.g. your username and password) each time you interact +with the repo, for example when pulling or pushing changes. chezmoi itself does +not store any credentials, but instead relies on your local git configuration +for these operations. + +When using a private repo on GitHub without `--ssh`, when prompted for a +password you will need to enter a [GitHub personal access +token](https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token). +For more information on these changes, read the [GitHub blog post on Token +authentication requirements for Git +operations](https://github.blog/2020-12-15-token-authentication-requirements-for-git-operations/) + +## Create a config file on a new machine automatically + +`chezmoi init` can also create a config file automatically, if one does not +already exist. If your repo contains a file called `.chezmoi.$FORMAT.tmpl` +where `$FORMAT` is one of the supported config file formats (e.g. `json`, +`jsonc`, `toml`, or `yaml`) then `chezmoi init` will execute that template to +generate your initial config file. + +Specifically, if you have `.chezmoi.toml.tmpl` that looks like this: + +``` title="~/.local/share/chezmoi/.chezmoi.toml.tmpl" +{{- $email := promptStringOnce . "email" "Email address" -}} + +[data] + email = {{ $email | quote }} +``` + +Then `chezmoi init` will create an initial `chezmoi.toml` using this template. +`promptStringOnce` is a special function that prompts the user (you) for a value +if it is not already set in your `data`. + +To test this template, use `chezmoi execute-template` with the `--init` and +`--promptString` flags, for example: + +```console +$ chezmoi execute-template --init --promptString email=me@home.org < ~/.local/share/chezmoi/.chezmoi.toml.tmpl +``` + +## Re-create your config file + +If you change your config file template, chezmoi will warn you if your current +config file was not generated from that template. You can re-generate your +config file by running: + +```console +$ chezmoi init +``` + +If you are using any `prompt*` template functions in your config file template +you will be prompted again. However, you can avoid this with the following +example template logic: + +``` +{{- $email := promptStringOnce . "email" "Email address" -}} + +[data] + email = {{ $email | quote }} +``` + +This will cause chezmoi use the `email` variable from your `data` and fallback +to `promptString` only if it is not set. diff --git a/docs/TEMPLATING.md b/assets/chezmoi.io/docs/user-guide/templating.md similarity index 64% rename from docs/TEMPLATING.md rename to assets/chezmoi.io/docs/user-guide/templating.md index e24748a7260..07c5368dd5e 100644 --- a/docs/TEMPLATING.md +++ b/assets/chezmoi.io/docs/user-guide/templating.md @@ -1,28 +1,4 @@ -# chezmoi templating guide - - -* [Introduction](#introduction) -* [Template data](#template-data) -* [Creating a template file](#creating-a-template-file) -* [Editing a template file](#editing-a-template-file) -* [Testing templates](#testing-templates) -* [Template syntax](#template-syntax) - * [Removing whitespace](#removing-whitespace) -* [Debugging templates](#debugging-templates) -* [Simple logic](#simple-logic) - * [Boolean functions](#boolean-functions) - * [Integer functions](#integer-functions) -* [More complicated logic](#more-complicated-logic) - * [Chaining operators](#chaining-operators) -* [Helper functions](#helper-functions) -* [Template variables](#template-variables) -* [Using `.chezmoitemplates`](#using-chezmoitemplates) -* [Using `.chezmoitemplates` for creating similar files](#using-chezmoitemplates-for-creating-similar-files) - * [Passing multiple arguments](#passing-multiple-arguments) -* [Useful templates](#useful-templates) - * [Determine whether the current machine is a laptop or desktop](#determine-whether-the-current-machine-is-a-laptop-or-desktop) - ---- +# Templating ## Introduction @@ -38,11 +14,10 @@ When reading files from the source state, chezmoi interprets them as a template if either of the following is true: * The file name has a `.tmpl` suffix. + * The file is in the `.chezmoitemplates` directory, or a subdirectory of `.chezmoitemplates`. ---- - ## Template data chezmoi provides a variety of template variables. For a full list, run @@ -54,57 +29,49 @@ $ chezmoi data These come from a variety of sources (later data overwrite earlier ones): * Variables populated by chezmoi are in `.chezmoi`, for example `.chezmoi.os`. -* Variables created by you in the `.chezmoidata.` configuration file. - The various supported formats (json, toml and yaml) are read in alphabetical - order. + +* Variables created by you in the `.chezmoidata.$FORMAT` configuration file. + The various supported formats (`json`, `jsonc`, `toml` and `yaml`) are read in + alphabetical order. + * Variables created by you in the `data` section of the configuration file. Furthermore, chezmoi provides a variety of functions to retrieve data at runtime from password managers, environment variables, and the filesystem. ---- - ## Creating a template file There are several ways to create a template: * When adding a file for the first time, pass the `--template` argument, for example: -```console -$ chezmoi add --template ~/.zshrc -``` - -* When adding a file for the first time, you can pass the `--autotemplate` - argument, which tells chezmoi to make the file as a template and automatically - replace variables that chezmoi knows about, for example: - -```console -$ chezmoi add --autotemplate ~/.zshrc -``` + ```console + $ chezmoi add --template ~/.zshrc + ``` * If a file is already managed by chezmoi, but is not a template, you can make it a template by running, for example: -```console -$ chezmoi chattr +template ~/.zshrc -``` + ```console + $ chezmoi chattr +template ~/.zshrc + ``` * You can create a template manually in the source directory by giving it a `.tmpl` extension, for example: -```console -$ chezmoi cd -$ $EDITOR dot_zshrc.tmpl -``` + ```console + $ chezmoi cd + $ $EDITOR dot_zshrc.tmpl + ``` * Templates in `.chezmoitemplates` must be created manually, for example: - chezmoi cd - mkdir -p .chezmoitemplates - cd .chezmoitemplates - $EDITOR mytemplate - ---- + ```console + $ chezmoi cd + $ mkdir -p .chezmoitemplates + $ cd .chezmoitemplates + $ $EDITOR mytemplate + ``` ## Editing a template file @@ -124,27 +91,31 @@ editor, use the `--apply` option, for example: $ chezmoi edit --apply ~/.zshrc ``` ---- - ## Testing templates -Templates can be tested with the `chezmoi execute-template` command which treats -each of its arguments as a template and executes it. This can be useful for -testing small fragments of templates, for example: +Templates can be tested and debugged with `chezmoi execute-template`, +which treats each of its arguments as a template and executes it. +The templates are interpreted and the results are output to standard +output, making it useful for testing small template fragments: ```console $ chezmoi execute-template '{{ .chezmoi.hostname }}' ``` -If there are no arguments, `chezmoi execute-template` will read the template -from the standard input. This can be useful for testing whole files, for example: +Without arguments, `chezmoi execute-template` will read the template from +standard input, which is useful for testing whole files: ```console $ chezmoi cd $ chezmoi execute-template < dot_zshrc.tmpl ``` ---- +If file redirection does not work (as when using PowerShell), the contents +of a file can be piped into `chezmoi execute-template`: + +```console +$ cat foo.txt | chezmoi execute-template +``` ## Template syntax @@ -162,9 +133,9 @@ Conditional expressions can be written using `if`, `else if`, `else`, and `end`, for example: ``` -{{ if (eq .chezmoi.os "darwin") }} +{{ if eq .chezmoi.os "darwin" }} # darwin -{{ else if (eq .chezmoi.os "linux" ) }} +{{ else if eq .chezmoi.os "linux" }} # linux {{ else }} # other operating system @@ -174,8 +145,6 @@ for example: For a full description of the template syntax, see the [`text/template` documentation](https://pkg.go.dev/text/template). ---- - ### Removing whitespace For formatting reasons you might want to leave some whitespace after or before @@ -199,33 +168,6 @@ HOSTNAME=myhostname Notice that this will remove any number of tabs, spaces and even newlines and carriage returns. ---- - -## Debugging templates - -If there is a mistake in one of your templates and you want to debug it, chezmoi -can help you. You can use this subcommand to test and play with the examples in -these docs as well. - -There is a very handy subcommand called `execute-template`. chezmoi will -interpret any data coming from stdin or at the end of the command. It will then -interpret all templates and output the result to stdout. For example with the -command: - -```console -$ chezmoi execute-template '{{ .chezmoi.os }}/{{ .chezmoi.arch }}' -``` - -chezmoi will output the current OS and architecture to stdout. - -You can also feed the contents of a file to this command by typing: - -```console -$ cat foo.txt | chezmoi execute-template -``` - ---- - ## Simple logic A very useful feature of chezmoi templates is the ability to perform logical @@ -245,32 +187,26 @@ In this example chezmoi will look at the hostname of the machine and if that is equal to "work-laptop", the text between the `if` and the `end` will be included in the result. ---- - ### Boolean functions -| Function | Return value | -| -------- | --------------------------------------------------------- | -| `eq` | Returns true if the first argument is equal to any of the other arguments. | -| `not` | Returns the boolean negation of its single argument. | -| `and` | Returns the boolean AND of its arguments by returning the first empty argument or the last argument, that is, `and x y` behaves as `if x then y else x`. All the arguments are evaluated. | -| `or` | Returns the boolean OR of its arguments by returning the first non-empty argument or the last argument, that is, `or x y` behaves as `if x then x else y` All the arguments are evaluated. | - ---- +| Function | Return value | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `eq` | Returns true if the first argument is equal to any of the other arguments | +| `not` | Returns the boolean negation of its single argument | +| `and` | Returns the boolean AND of its arguments by returning the first empty argument or the last argument, that is, `and x y` behaves as `if x then y else x`. All the arguments are evaluated | +| `or` | Returns the boolean OR of its arguments by returning the first non-empty argument or the last argument, that is, `or x y` behaves as `if x then x else y` All the arguments are evaluated | ### Integer functions -| Function | Return value | -| -------- | ------------------------------------------- | -| `len` | Returns the integer length of its argument. | -| `eq` | Returns the boolean truth of arg1 == arg2. | -| `ne` | Returns the boolean truth of arg1 != arg2. | -| `lt` | Returns the boolean truth of arg1 < arg2. | -| `le` | Returns the boolean truth of arg1 <= arg2. | -| `gt` | Returns the boolean truth of arg1 > arg2. | -| `ge` | Returns the boolean truth of arg1 >= arg2. | - ---- +| Function | Return value | +| -------- | ------------------------------------------ | +| `len` | Returns the integer length of its argument | +| `eq` | Returns the boolean truth of arg1 == arg2 | +| `ne` | Returns the boolean truth of arg1 != arg2 | +| `lt` | Returns the boolean truth of arg1 < arg2 | +| `le` | Returns the boolean truth of arg1 <= arg2 | +| `gt` | Returns the boolean truth of arg1 > arg2 | +| `ge` | Returns the boolean truth of arg1 >= arg2 | ## More complicated logic @@ -292,8 +228,6 @@ nothing. The operators `or` and `and` can also accept multiple arguments. ---- - ### Chaining operators You can perform multiple checks in one if statement. @@ -310,8 +244,6 @@ arguments will be give to the `and` command. This way you can chain as many operators together as you like. ---- - ## Helper functions chezmoi has added multiple helper functions to the @@ -322,16 +254,13 @@ the `text/template` format that contains many helper functions. Take a look at their documentation for a list. chezmoi adds a few functions of its own as well. Take a look at the -[reference](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#template-functions) -for complete list. - ---- +[reference](../reference/templates/functions/index.md) for complete list. ## Template variables chezmoi defines a few useful templates variables that depend on the system you are currently on. A list of the variables defined by chezmoi can be found -[here](REFERENCE.md#template-variables). +[here](../reference/templates/variables.md). There are, however more variables than that. To view the variables available on your system, execute: @@ -349,8 +278,6 @@ This outputs the variables in JSON format by default. To access the variable This way you can also access the variables you defined yourself. ---- - ## Using `.chezmoitemplates` Files in the `.chezmoitemplates` subdirectory are parsed as templates and are @@ -376,8 +303,6 @@ dot_file.tmpl: {{ template "part.tmpl" . }} ``` ---- - ## Using `.chezmoitemplates` for creating similar files When you have multiple similar files, but they aren't quite the same, you can @@ -395,13 +320,14 @@ more: config Notice the file name doesn't have to end in `.tmpl`, as all files in the directory `.chezmoitemplates` are interpreted as templates. -Create other files using the template `.local/share/chezmoi/small-font.yml.tmpl` +Create other files using the template +`~/.local/share/chezmoi/small-font.yml.tmpl` ``` {{- template "alacritty" 12 -}} ``` -`.local/share/chezmoi/big-font.yml.tmpl` +`~/.local/share/chezmoi/big-font.yml.tmpl` ``` {{- template "alacritty" 18 -}} @@ -421,22 +347,18 @@ fontsize: 18 more: config ``` ---- - ### Passing multiple arguments In the example above only one arguments is passed to the template. To pass more arguments to the template, you can do it in two ways. ---- - #### Via the config file This method is useful if you want to use the same template arguments multiple times, because you don't specify the arguments every time. Instead you specify -them in the file `.config/chezmoi/.chezmoi.toml`: +them in the file `~/.config/chezmoi/chezmoi.toml`: -```toml +```toml title="~/.config/chezmoi/chezmoi.toml" [data.alacritty.big] fontsize = 18 font = "DejaVu Serif" @@ -445,59 +367,29 @@ them in the file `.config/chezmoi/.chezmoi.toml`: font = "DejaVu Sans Mono" ``` -Use the variables in `.local/share/chezmoi/.chezmoitemplates/alacritty`: +Use the variables in `~/.local/share/chezmoi/.chezmoitemplates/alacritty`: -``` +``` title="~/.local/share/chezmoi/.chezmoitemplates/alacritty" some: config fontsize: {{ .fontsize }} font: {{ .font }} more: config ``` -And connect them with `.local/share/chezmoi/small-font.yml.tmpl`: +And connect them with `~/.local/share/chezmoi/small-font.yml.tmpl`: -``` +``` title="~/.local/share/chezmoi/small-font.yml.tmpl" {{- template "alacritty" .alacritty.small -}} ``` At the moment, this means that you'll have to duplicate the alacritty data in the config file on every machine, but a feature will be added to avoid this. ---- - #### By passing a dictionary Using the same alacritty configuration as above, you can pass the arguments to -it with a dictionary, for example `.local/share/chezmoi/small-font.yml.tmpl`: +it with a dictionary, for example `~/.local/share/chezmoi/small-font.yml.tmpl`: -``` +``` title="~/.local/share/chezmoi/small-font.yml.tmpl" {{- template "alacritty" dict "fontsize" 12 "font" "DejaVu Sans Mono" -}} ``` - ---- - -## Useful templates - ---- - -### Determine whether the current machine is a laptop or desktop - -The following template sets the `$chassisType` variable to `"desktop"` or -`"laptop"` on macOS, Linux, and Windows. - -``` -{{- $chassisType := "desktop" }} -{{- if (eq .chezmoi.os "darwin") }} -{{- if contains "MacBook" (output "sysctl" "-n" "hw.model") }} -{{- $chassisType = "laptop" }} -{{- else }} -{{- $chassisType = "desktop" }} -{{- end }} -{{- else if (eq .chezmoi.os "linux") }} -{{- $chassisType = (output "hostnamectl" "--json=short" | mustFromJson).Chassis }} -{{- else if (eq .chezmoi.os "windows") }} -{{- $chassisType = (output "powershell.exe" "-noprofile" "-command" "if (Get-WmiObject -Class win32_battery -ComputerName localhost) { echo laptop } else { echo desktop }") }} -{{- end }} -``` - ---- diff --git a/assets/chezmoi.io/docs/user-guide/tools/diff.md b/assets/chezmoi.io/docs/user-guide/tools/diff.md new file mode 100644 index 00000000000..e524a74a799 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/tools/diff.md @@ -0,0 +1,106 @@ +# Diff + +## Use a custom diff tool + +By default, chezmoi uses a built-in diff. You can use a custom tool by setting +the `diff.command` and `diff.args` configuration variables. The elements of +`diff.args` are interpreted as templates with the variables `.Destination` and +`.Target` containing filenames of the file in the destination state and the +target state respectively. For example, to use [meld](https://meldmerge.org/), +specify: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[diff] + command = "meld" + args = ["--diff", "{{ .Destination }}", "{{ .Target }}"] +``` + +!!! hint + + If you generate your config file from a config file template, then you'll + need to escape the `{{` and `}}` as `{{ "{{" }}` and `{{ "}}" }}`. That way + your generated config file contains the `{{` and `}}` you expect. + +## Don't show scripts in the diff output + +By default, `chezmoi diff` will show all changes, including the contents of +scripts that will be run. You can exclude scripts from the diff output by +setting the `diff.exclude` configuration variable in your configuration file, +for example: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[diff] + exclude = ["scripts"] +``` + +## Don't show externals in the diff output + +To exclude diffs from externals, either pass the `--exclude=externals` flag or +set `diff.exclude` to `["externals"]` in your config file. + +## Customize the diff pager + +You can change the diff format, and/or pipe the output into a pager of your +choice by setting `diff.pager` configuration variable. For example, to use +[`diff-so-fancy`](https://github.com/so-fancy/diff-so-fancy) specify: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[diff] + pager = "diff-so-fancy" +``` + +The pager can be disabled using the `--no-pager` flag or by setting `diff.pager` +to an empty string. + +## Show human-friendly diffs for binary files + +Similar to git, chezmoi includes a "textconv" feature that can transform file +contents before passing them to the diff program. This is primarily useful for +generating human-readable diffs of binary files. + +For example, to show diffs of macOS `.plist` files, add the following to your +configuration file: + +=== "JSON" + + ```json title="~/.config/chezmoi/chezmoi.json" + { + "textconv": [ + "pattern": "**/*.plist", + "command": "plutil", + "args": [ + "-convert", + "xml1", + "-o", + "-", + "-" + ] + ] + } + ``` + +=== "TOML" + + ```toml title="~/.config/chezmoi/chezmoi.toml" + [[textconv]] + pattern = "**/*.plist" + command = "plutil" + args = ["-convert", "xml1", "-o", "-", "-"] + ``` + +=== "YAML" + + ```yaml title="~/.config/chezmoi/chezmoi.yaml" + textconv: + - pattern: "**/*.plist" + command: "plutil" + args: + - "-convert" + - "xml1" + - "-o" + - "-", + - "-" + ``` + +This will pipe all `.plist` files through `plutil -convert xml1 -o - -` before +showing differences. diff --git a/assets/chezmoi.io/docs/user-guide/tools/editor.md b/assets/chezmoi.io/docs/user-guide/tools/editor.md new file mode 100644 index 00000000000..fea3c5e6250 --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/tools/editor.md @@ -0,0 +1,61 @@ +# Editor + +## Use your preferred editor with `chezmoi edit` and `chezmoi edit-config` + +By default, chezmoi will use your preferred editor as defined by the `$VISUAL` +or `$EDITOR` environment variables, falling back to a default editor depending +on your operating system (`vi` on UNIX-like operating systems, `notepad.exe` on +Windows). + +You can configure chezmoi to use your preferred editor by either setting the +`$EDITOR` environment variable or setting the `edit.command` variable in your +configuration file. + +The editor command must only return when you have finished editing the files. +chezmoi will emit a warning if your editor command returns too quickly. + +In the specific case of using [VSCode](https://code.visualstudio.com/) or +[Codium](https://vscodium.com/) as your editor, you must pass the `--wait` +flag, for example, in your shell config: + +```console +$ export EDITOR="code --wait" +``` + +Or in chezmoi's configuration file: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[edit] + command = "code" + args = ["--wait"] +``` + +## Use chezmoi with VIM + +[`github.com/alker0/chezmoi.vim`](https://github.com/alker0/chezmoi.vim) +provides syntax highlighting for files managed by chezmoi, including for +templates. + +[`github.com/Lilja/vim-chezmoi`](https://github.com/Lilja/vim-chezmoi) works +with `chezmoi edit` to apply the edited dotfile on save. + +[`github.com/xvzc/chezmoi.nvim`](https://github.com/xvzc/chezmoi.nvim) allows +you to edit your chezmoi-managed files and automatically apply. + +Alternatively, you can use an `autocmd` to run `chezmoi apply` whenever you save +a dotfile, but you must disable `chezmoi edit`'s hardlinking: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[edit] + hardlink = false +``` + +```vim title="~/.vimrc" +autocmd BufWritePost ~/.local/share/chezmoi/* ! chezmoi apply --source-path "%" +``` + +## Use chezmoi with emacs + +[`github.com/tuh8888/chezmoi.el`](https://github.com/tuh8888/chezmoi.el) +provides convenience functions for interacting with chezmoi from emacs, and is +available in [MELPA](https://melpa.org/#/chezmoi). diff --git a/assets/chezmoi.io/docs/user-guide/tools/http-or-socks5-proxy.md b/assets/chezmoi.io/docs/user-guide/tools/http-or-socks5-proxy.md new file mode 100644 index 00000000000..83c67a7a1ba --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/tools/http-or-socks5-proxy.md @@ -0,0 +1,9 @@ +# HTTP or SOCKS5 proxy + +chezmoi supports HTTP, HTTPS, and SOCKS5 proxies. Set the `HTTP_PROXY`, +`HTTPS_PROXY`, and `NO_PROXY` environment variables, or their lowercase +equivalents, for example: + +```console +$ HTTP_PROXY=socks5://127.0.0.1:1080 chezmoi apply --refresh-externals +``` diff --git a/assets/chezmoi.io/docs/user-guide/tools/merge.md b/assets/chezmoi.io/docs/user-guide/tools/merge.md new file mode 100644 index 00000000000..b0a36097dbd --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/tools/merge.md @@ -0,0 +1,22 @@ +# Merge + +## Use a custom merge command + +By default, chezmoi uses `vimdiff`. You can use a custom command by setting the +`merge.command` and `merge.args` configuration variables. The elements of +`merge.args` are interpreted as templates with the variables `.Destination`, +`.Source`, and `.Target` containing filenames of the file in the destination +state, source state, and target state respectively. For example, to use +[neovim's diff mode](https://neovim.io/doc/user/diff.html), specify: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[merge] + command = "nvim" + args = ["-d", "{{ .Destination }}", "{{ .Source }}", "{{ .Target }}"] +``` + +!!! hint + + If you generate your config file from a config file template, then you'll + need to escape the `{{` and `}}` as `{{ "{{" }}` and `{{ "}}" }}`. That way + your generated config file contains the `{{` and `}}` you expect. diff --git a/assets/chezmoi.io/docs/user-guide/use-scripts-to-perform-actions.md b/assets/chezmoi.io/docs/user-guide/use-scripts-to-perform-actions.md new file mode 100644 index 00000000000..2977a51e1af --- /dev/null +++ b/assets/chezmoi.io/docs/user-guide/use-scripts-to-perform-actions.md @@ -0,0 +1,148 @@ +# Use scripts to perform actions + +## Understand how scripts work + +chezmoi supports scripts, which are executed when you run `chezmoi apply`. The +scripts can either run every time you run `chezmoi apply`, only when their +contents have changed, or only if they have not been run before. + +In verbose mode, the script's contents will be printed before executing it. In +dry-run mode, the script is not executed. + +Scripts are any file in the source directory with the prefix `run_`, and are +executed in alphabetical order. Scripts that should be run whenever their +contents change have the `run_onchange_` prefix. Scripts that should only be run +if they have not been run before have the prefix `run_once_`. + +Scripts break chezmoi's declarative approach, and as such should be used +sparingly. Any script should be idempotent, even `run_onchange_` and `run_once_` +scripts. + +Scripts are normally run while chezmoi updates your dotfiles. To configure +scripts to run before or after your dotfiles are updated use the `before_` and +`after_` attributes respectively, e.g. +`run_once_before_install-password-manager.sh`. + +Scripts must be created manually in the source directory, typically by running +`chezmoi cd` and then creating a file with a `run_` prefix. There is no need to +set the executable bit on the script. + +Scripts with the suffix `.tmpl` are treated as templates, with the usual +template variables available. If, after executing the template, the result is +only whitespace or an empty string, then the script is not executed. This is +useful for disabling scripts. + +When chezmoi executes a script, it first generates the script contents in a +file in a temporary directory with the executable bit set, and then executes +the contents with `exec(3)`. Consequently, the script's contents must either +include a `#!` line or be an executable binary. + +## Set environment variables + +You can set extra environment variables for your scripts in the `scriptEnv` +section of your config file. For example, to set the `MY_VAR` environment +variable to `my_value`, specify: + +```toml title="~/.config/chezmoi/chezmoi.toml" +[scriptEnv] + MY_VAR = "my_value" +``` + +chezmoi sets a number of environment variables when running scripts, including +`CHEZMOI=1` and common template data like `CHEZMOI_OS` and `CHEZMOI_ARCH`. + +!!! note + + By default, `chezmoi diff` will print the contents of scripts that would be + run by `chezmoi apply`. To exclude scripts from the output of `chezmoi + diff`, set `diff.exclude` in your configuration file, for example: + + ```toml title="~/.config/chezmoi/chezmoi.toml" + [diff] + exclude = ["scripts"] + ``` + + Similarly, `chezmoi status` will print the names of the scripts that it + will execute with the status `R`. This can similarly disabled by setting + `status.exclude` to `["scripts"]` in your configuration file. + +## Install packages with scripts + +Change to the source directory and create a file called +`run_onchange_install-packages.sh`: + +```console +$ chezmoi cd +$ $EDITOR run_onchange_install-packages.sh +``` + +In this file create your package installation script, e.g. + +```sh +#!/bin/sh +sudo apt install ripgrep +``` + +The next time you run `chezmoi apply` or `chezmoi update` this script will be +run. As it has the `run_onchange_` prefix, it will not be run again unless its +contents change, for example if you add more packages to be installed. + +This script can also be a template. For example, if you create +`run_onchange_install-packages.sh.tmpl` with the contents: + +``` title="~/.local/share/chezmoi/run_onchange_install-packages.sh.tmpl" +{{ if eq .chezmoi.os "linux" -}} +#!/bin/sh +sudo apt install ripgrep +{{ else if eq .chezmoi.os "darwin" -}} +#!/bin/sh +brew install ripgrep +{{ end -}} +``` + +This will install `ripgrep` on both Debian/Ubuntu Linux systems and macOS. + +## Run a script when the contents of another file changes + +chezmoi's `run_` scripts are run every time you run `chezmoi apply`, whereas +`run_onchange_` scripts are run only when their contents have changed, after +executing them as templates. You can use this to cause a `run_onchange_` script +to run when the contents of another file has changed by including a checksum of +the other file's contents in the script. + +For example, if your [dconf](https://wiki.gnome.org/Projects/dconf) settings +are stored in `dconf.ini` in your source directory then you can make `chezmoi +apply` only load them when the contents of `dconf.ini` has changed by adding +the following script as `run_onchange_dconf-load.sh.tmpl`: + +``` title="~/.local/share/chezmoi/run_onchange_dconf-load.sh.tmpl" +#!/bin/bash + +# dconf.ini hash: {{ include "dconf.ini" | sha256sum }} +dconf load / < {{ joinPath .chezmoi.sourceDir "dconf.ini" | quote }} +``` + +As the SHA256 sum of `dconf.ini` is included in a comment in the script, the +contents of the script will change whenever the contents of `dconf.ini` are +changed, so chezmoi will re-run the script whenever the contents of `dconf.ini` +change. + +In this example you should also add `dconf.ini` to `.chezmoiignore` so chezmoi +does not create `dconf.ini` in your home directory. + +## Clear the state of all `run_onchange_` and `run_once_` scripts + +chezmoi stores whether and when `run_onchange_` and `run_once_` scripts have +been run in its persistent state. + +To clear the state of `run_onchange_` scripts, run: + +```console +$ chezmoi state delete-bucket --bucket=entryState +``` + +To clear the state of `run_once_` scripts, run: + +```console +$ chezmoi state delete-bucket --bucket=scriptState +``` diff --git a/assets/chezmoi.io/docs/what-does-chezmoi-do.md b/assets/chezmoi.io/docs/what-does-chezmoi-do.md new file mode 100644 index 00000000000..488c161374f --- /dev/null +++ b/assets/chezmoi.io/docs/what-does-chezmoi-do.md @@ -0,0 +1,85 @@ +# What does chezmoi do? + +chezmoi helps you manage your personal configuration files (dotfiles, like +`~/.gitconfig`) across multiple machines. + +chezmoi is helpful if you have spent time customizing the tools you use (e.g. +shells, editors, and version control systems) and want to keep machines running +different accounts (e.g. home and work) and/or different operating systems +(e.g. Linux, macOS, and Windows) in sync, while still being able to easily cope +with differences from machine to machine. + +chezmoi scales from the trivial (e.g. copying a few dotfiles onto a Raspberry +Pi, development container, or virtual machine) to complex long-lived +multi-machine development environments (e.g. keeping any number of home and +work, Linux, macOS, and Windows machines in sync). In all cases you only need +to maintain a single source of truth (a single branch in git) and getting +started only requires adding a single binary to your machine (which you can do +with `curl`, `wget`, or `scp`). + +chezmoi has strong support for security, allowing you to manage secrets (e.g. +passwords, access tokens, and private keys) securely and seamlessly using a +password manager and/or encrypt whole files with your favorite encryption tool. + +If you do not personalize your configuration or only ever use a single +operating system with a single account and none of your dotfiles contain +secrets then you don't need chezmoi. Otherwise, read on... + +## What are chezmoi's key features? + +### Flexible + +You can share as much configuration across machines as you want, while still +being able to control machine-specific details.Your dotfiles can be templates +(using [`text/template`](https://pkg.go.dev/text/template) syntax). Predefined +variables allow you to change behavior depending on operating system, +architecture, and hostname. chezmoi runs on all commonly-used platforms, like +Linux, macOS, and Windows. It also runs on less commonly-used platforms, like +FreeBSD, OpenBSD, and Termux. + +### Personal and secure + +Nothing leaves your machine, unless you want it to. Your configuration remains +in a git repo under your control. You can write the configuration file in the +format of your choice. chezmoi can retrieve secrets from +[1Password](https://1password.com/), [AWS Secrets +Manager](https://aws.amazon.com/secrets-manager/), [Azure Key +Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/), +[Bitwarden](https://bitwarden.com/), [Dashlane](https://www.dashlane.com/), +[Doppler](https://www.doppler.com), [gopass](https://www.gopass.pw/), [HCP Vault +Secrets](https://developer.hashicorp.com/hcp/docs/vault-secrets), +[KeePassXC](https://keepassxc.org/), [Keeper](https://www.keepersecurity.com/), +[LastPass](https://lastpass.com/), [pass](https://www.passwordstore.org/), +[passage](https://github.com/FiloSottile/passage), +[passhole](https://github.com/Evidlo/passhole), +[Vault](https://www.vaultproject.io/), Keychain, +[Keyring](https://wiki.gnome.org/Projects/GnomeKeyring), or any command-line +utility of your choice. You can encrypt individual files with +[GnuPG](https://www.gnupg.org) or [age](https://age-encryption.org). You can +checkout your dotfiles repo on as many machines as you want without revealing +any secrets to anyone. + +### Transparent + +chezmoi includes verbose and dry run modes so you can review exactly what +changes it will make to your home directory before making them. chezmoi's +source format uses only regular files and directories that map one-to-one with +the files, directories, and symlinks in your home directory that you choose to +manage. If you decide not to use chezmoi in the future, it is easy to move your +data elsewhere. + +### Declarative and robust + +You declare the desired state of files, directories, and symbolic links in your +source of truth and chezmoi updates your home directory to match that state. +What you want is what you get. chezmoi updates all files and symbolic links +atomically. You will never be left with incomplete files that could lock you +out, even if the update process is interrupted. + +### Fast and easy to use + +Using chezmoi feels like using git: the commands are similar and chezmoi runs +in fractions of a second. chezmoi makes most day-to-day operations one line +commands, including installation, initialization, and keeping your machines +up-to-date. chezmoi can pull and apply changes from your dotfiles repo in a +single command, and automatically commit and push changes. diff --git a/assets/chezmoi.io/docs/why-use-chezmoi.md b/assets/chezmoi.io/docs/why-use-chezmoi.md new file mode 100644 index 00000000000..ceba997709c --- /dev/null +++ b/assets/chezmoi.io/docs/why-use-chezmoi.md @@ -0,0 +1,159 @@ +# Why use chezmoi? + +## Why should I use a dotfile manager? + +Dotfile managers give you the combined benefit of a consistent environment +everywhere with an undo command and a restore from backup. + +As the core of our development environments become increasingly standardized +(e.g. using git at both home and work), and we further customize them, at the +same time we increasingly work in ephemeral environments like Docker +containers, virtual machines, and GitHub Codespaces. + +In the same way that nobody would use an editor without an undo command, or +develop software without a version control system, chezmoi brings the +investment that you have made in mastering your tools to every environment that +you work in. + +## I already have a system to manage my dotfiles, why should I use chezmoi? + +!!! quote + + I’ve been using Chezmoi for more than a year now, across at least 3 + computers simultaneously, and I really love it. Most of all, I love how + fast I can configure a new machine when I use it. In just a couple minutes + of work, I can kick off a process on a brand-new computer that will set up + my dotfiles and install all my usual software so it feels like a computer + I’ve been using for years. I also appreciate features like secrets + management, which allow me to share my dotfiles while keeping my secrets + safe. Overall, I love the way Chezmoi fits so perfectly into the niche of + managing dotfiles. + + — [@mike_kasberg](https://www.mikekasberg.com/blog/2021/05/12/my-dotfiles-story.html) + +!!! quote + + I had initially been turned off when I first encountered [chezmoi], because + [chezmoi] seemed overkill for (what appeared to me) a simple task. + + But the problem of managing a relatively small number of dotfiles across a + relatively small number of machines with small differences between them and + keeping them up to date proved to be _MUCH_ more complex than I imagined. + Copy things around by hand, and then later distributing them via source + control got hairy very quickly. + + I finally realized all those features were absolutely necessary to manage + things sanely, and once I took some time to learn how to do things with + chezmoi, I have never looked back. + + — [njt](https://news.ycombinator.com/item?id=31015669) + +!!! quote + + Regular reminder that chezmoi is the best dotfile manager utility I've used + and you can too + + — [@mbbroberg](https://twitter.com/mbbroberg/status/1355644967625125892) + +If you're using any of the following methods: + +* A custom shell script. + +* An existing dotfile manager like + [dotbot](https://github.com/anishathalye/dotbot), + [rcm](https://github.com/thoughtbot/rcm), + [homesick](https://github.com/technicalpickles/homesick), + [vcsh](https://github.com/RichiH/vcsh), + [yadm](https://yadm.io/), or [GNU Stow](https://www.gnu.org/software/stow/). + +* A [bare git repo](https://www.atlassian.com/git/tutorials/dotfiles). + +Then you've probably run into at least one of the following problems. + +### ...if coping with differences between machines requires extra effort + +If you want to synchronize your dotfiles across multiple operating systems or +distributions, then you may need to manually perform extra steps to cope with +differences from machine to machine. You might need to run different commands on +different machines, maintain separate per-machine files or branches (with the +associated hassle of merging, rebasing, or copying each change), or hope that +your custom logic handles the differences correctly. + +chezmoi uses a single source of truth (a single branch) and a single command +that works on every machine. Individual files can be templates to handle machine +to machine differences, if needed. + +### ...if you have to keep your dotfiles repo private + +!!! quote + + And regarding dotfiles, I saw that. It's only public dotfiles repos so I + have to evaluate my dotfiles history to be sure. I have secrets scanning + and more, but it was easier to keep it private for security, I'm ok mostly + though. I'm using chezmoi and it's easier now + + — [@sheldon_hull](https://twitter.com/sheldon_hull/status/1308139570597371907) + +If your system stores secrets in plain text, then you must be very careful about +where you clone your dotfiles. If you clone them on your work machine then +anyone with access to your work machine (e.g. your IT department) will have +access to your home secrets. If you clone it on your home machine then you risk +leaking work secrets. + +With chezmoi you can store secrets in your password manager or encrypt them, and +even store passwords in different ways on different machines. You can clone your +dotfiles repository anywhere, and even make your dotfiles repo public, without +leaving personal secrets on your work machine or work secrets on your personal +machine. + +### ...if you have to maintain your own tool + +!!! quote + + I've offloaded my dotfiles deployment from a homespun shell script to chezmoi. + I'm quite happy with this decision. + + — [@gotgenes](https://twitter.com/gotgenes/status/1251008845163319297) + +!!! quote + + I discovered chezmoi and it's pretty cool, just migrated my old custom + multi-machine sync dotfile setup and it's so much simpler now + + in case you're wondering I have written 0 code + + — [@buritica](https://twitter.com/buritica/status/1361062902451630089) + +!!! quote + + Chezmoi is like what you might get if you re-wrote my bash script in Go, + came up with better solutions than `diff` for managing config on multiple + machines, added in secrets management and other useful dotfile tools, and + tweaked and perfected it over years. + + - [@mike_kasberg](https://www.mikekasberg.com/blog/2021/05/12/my-dotfiles-story.html) + +If your system was written by you for your personal use, then it probably has +the functionality that you needed when you wrote it. If you need more +functionality then you have to implement it yourself. + +chezmoi includes a huge range of battle-tested functionality out-of-the-box, +including dry-run and diff modes, script execution, conflict resolution, Windows +support, and much, much more. chezmoi is [used by thousands of +people](https://github.com/twpayne/chezmoi/stargazers) and has a rich suite of +both unit and integration tests. When you hit the limits of your existing +dotfile management system, chezmoi already has a tried-and-tested solution ready +for you to use. + +### ...if setting up your dotfiles requires more than one short command + +If your system is written in a scripting language like Python, Perl, or Ruby, +then you also need to install a compatible version of that language's runtime +before you can use your system. + +chezmoi is distributed as a single stand-alone statically-linked binary with no +dependencies that you can simply copy onto your machine and run. You don't even +need git installed. chezmoi provides one-line installs, pre-built binaries, +packages for Linux and BSD distributions, Homebrew formulae, Scoop and +Chocolatey support on Windows, and a initial config file generation mechanism to +make installing your dotfiles on a new machine as painless as possible. diff --git a/assets/chezmoi.io/make-gh-pages.sh b/assets/chezmoi.io/make-gh-pages.sh deleted file mode 100755 index 999ae7ab4ba..00000000000 --- a/assets/chezmoi.io/make-gh-pages.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash - -set -eo pipefail - -# generate the new website -rm -rf public/ -hugo - -# clone and checkout the gh-pages branch in a temporary directory -tmpdir=$(mktemp -d) -cleanup() { - rm -rf "${tmpdir}" -} -trap cleanup EXIT -git branch -f gh-pages origin/gh-pages -git clone --branch=gh-pages --local ../.. "${tmpdir}" - -# copy the new website to the temporary directory -rm -rf "${tmpdir:?}"/* -cp -r public/* "${tmpdir}" - -# prepare the clone -cd "${tmpdir}" -git checkout CNAME -git remote set-url origin https://github.com/twpayne/chezmoi.git -git fetch origin -git reset origin/gh-pages - -# commit the new website -if ! git diff --quiet; then - git add . - git commit --message "Update gh-pages" -fi - -# give the user the opportunity to push the new website -echo "run git push to push the new website" - -${SHELL} diff --git a/assets/chezmoi.io/mkdocs.yml b/assets/chezmoi.io/mkdocs.yml new file mode 100644 index 00000000000..31a9df256d9 --- /dev/null +++ b/assets/chezmoi.io/mkdocs.yml @@ -0,0 +1,368 @@ +site_name: chezmoi +site_url: https://chezmoi.io +site_description: Manage your dotfiles across multiple machines, securely. +site_author: Tom Payne +copyright: Copyright © Tom Payne 2018-2024 +repo_name: twpayne/chezmoi +repo_url: https://github.com/twpayne/chezmoi +edit_uri: edit/master/assets/chezmoi.io/docs/ + +theme: + name: material + logo: logo.svg + language: en + palette: + - media: '(prefers-color-scheme: light)' + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/toggle-switch + name: Switch to dark mode + - media: '(prefers-color-scheme: dark)' + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/toggle-switch-off-outline + name: Switch to light mode + features: + - navigation.expand + - navigation.indexes + - navigation.sections + - navigation.tabs + - navigation.top + - navigation.tracking + +nav: +- Home: + - index.md + - Install: install.md + - Quick start: quick-start.md + - What does chezmoi do?: what-does-chezmoi-do.md + - Why use chezmoi?: why-use-chezmoi.md + - Comparison table: comparison-table.md + - Migrating from another dotfile manager: migrating-from-another-dotfile-manager.md +- User guide: + - Command overview: user-guide/command-overview.md + - Setup: user-guide/setup.md + - Daily operations: user-guide/daily-operations.md + - Manage different types of file: user-guide/manage-different-types-of-file.md + - Include files from elsewhere: user-guide/include-files-from-elsewhere.md + - Manage machine-to-machine differences: user-guide/manage-machine-to-machine-differences.md + - Use scripts to perform actions: user-guide/use-scripts-to-perform-actions.md + - Templating: user-guide/templating.md + - Tools: + - Editor: user-guide/tools/editor.md + - Diff: user-guide/tools/diff.md + - Merge: user-guide/tools/merge.md + - HTTP or SOCKS5 proxy: user-guide/tools/http-or-socks5-proxy.md + - Password managers: + - user-guide/password-managers/index.md + - 1Password: user-guide/password-managers/1password.md + - AWS Secrets Manager: user-guide/password-managers/aws-secrets-manager.md + - Azure Key Vault: user-guide/password-managers/azure-key-vault.md + - Bitwarden: user-guide/password-managers/bitwarden.md + - Dashlane: user-guide/password-managers/dashlane.md + - Doppler: user-guide/password-managers/doppler.md + - ejson: user-guide/password-managers/ejson.md + - gopass: user-guide/password-managers/gopass.md + - Hashicorp Vault Secrets: user-guide/password-managers/hcp-vault-secrets.md + - KeePassXC: user-guide/password-managers/keepassxc.md + - Keychain and Windows Credentials Manager: user-guide/password-managers/keychain-and-windows-credentials-manager.md + - Keeper: user-guide/password-managers/keeper.md + - LastPass: user-guide/password-managers/lastpass.md + - pass: user-guide/password-managers/pass.md + - passhole: user-guide/password-managers/passhole.md + - Vault: user-guide/password-managers/vault.md + - Custom: user-guide/password-managers/custom.md + - Encryption: + - user-guide/encryption/index.md + - age: user-guide/encryption/age.md + - gpg: user-guide/encryption/gpg.md + - rage: user-guide/encryption/rage.md + - Machines: + - General: user-guide/machines/general.md + - Linux: user-guide/machines/linux.md + - macOS: user-guide/machines/macos.md + - Windows: user-guide/machines/windows.md + - Containers and VMs: user-guide/machines/containers-and-vms.md + - Advanced: + - Customize your source directory: user-guide/advanced/customize-your-source-directory.md + - Install packages declaratively: user-guide/advanced/install-packages-declaratively.md + - Install your password manager on init: user-guide/advanced/install-your-password-manager-on-init.md + - Use chezmoi with Watchman: user-guide/advanced/use-chezmoi-with-watchman.md + - Migrate away from chezmoi: user-guide/advanced/migrate-away-from-chezmoi.md + - Frequently asked questions: + - Usage: user-guide/frequently-asked-questions/usage.md + - Encryption: user-guide/frequently-asked-questions/encryption.md + - Troubleshooting: user-guide/frequently-asked-questions/troubleshooting.md + - Design: user-guide/frequently-asked-questions/design.md + - General: user-guide/frequently-asked-questions/general.md +- Reference: + - reference/index.md + - Concepts: reference/concepts.md + - Source state attributes: reference/source-state-attributes.md + - Target types: reference/target-types.md + - Application order: reference/application-order.md + - Command line flags: + - reference/command-line-flags/index.md + - Global: reference/command-line-flags/global.md + - Common: reference/command-line-flags/common.md + - Developer: reference/command-line-flags/developer.md + - Configuration file: + - reference/configuration-file/index.md + - Variables: reference/configuration-file/variables.md + - Editor: reference/configuration-file/editor.md + - Hooks: reference/configuration-file/hooks.md + - pinentry: reference/configuration-file/pinentry.md + - textconv: reference/configuration-file/textconv.md + - umask: reference/configuration-file/umask.md + - Warnings: reference/configuration-file/warnings.md + - Special files and directories: + - reference/special-files-and-directories/index.md + - .chezmoi.<format>.tmpl: reference/special-files-and-directories/chezmoi-format-tmpl.md + - .chezmoidata.<format>: reference/special-files-and-directories/chezmoidata-format.md + - .chezmoiexternal.<format>: reference/special-files-and-directories/chezmoiexternal-format.md + - .chezmoiexternals: reference/special-files-and-directories/chezmoiexternals.md + - .chezmoiignore: reference/special-files-and-directories/chezmoiignore.md + - .chezmoiremove: reference/special-files-and-directories/chezmoiremove.md + - .chezmoiroot: reference/special-files-and-directories/chezmoiroot.md + - .chezmoiscripts: reference/special-files-and-directories/chezmoiscripts.md + - .chezmoitemplates: reference/special-files-and-directories/chezmoitemplates.md + - .chezmoiversion: reference/special-files-and-directories/chezmoiversion.md + - Commands: + - add: reference/commands/add.md + - age: reference/commands/age.md + - apply: reference/commands/apply.md + - archive: reference/commands/archive.md + - cat: reference/commands/cat.md + - cat-config: reference/commands/cat-config.md + - cd: reference/commands/cd.md + - chattr: reference/commands/chattr.md + - completion: reference/commands/completion.md + - data: reference/commands/data.md + - decrypt: reference/commands/decrypt.md + - destroy: reference/commands/destroy.md + - diff: reference/commands/diff.md + - doctor: reference/commands/doctor.md + - dump: reference/commands/dump.md + - dump-config: reference/commands/dump-config.md + - edit: reference/commands/edit.md + - edit-config: reference/commands/edit-config.md + - edit-config-template: reference/commands/edit-config-template.md + - encrypt: reference/commands/encrypt.md + - execute-template: reference/commands/execute-template.md + - forget: reference/commands/forget.md + - generate: reference/commands/generate.md + - git: reference/commands/git.md + - help: reference/commands/help.md + - init: reference/commands/init.md + - import: reference/commands/import.md + - ignored: reference/commands/ignored.md + - license: reference/commands/license.md + - list: reference/commands/list.md + - manage: reference/commands/manage.md + - managed: reference/commands/managed.md + - merge: reference/commands/merge.md + - merge-all: reference/commands/merge-all.md + - purge: reference/commands/purge.md + - re-add: reference/commands/re-add.md + - remove: reference/commands/remove.md + - rm: reference/commands/rm.md + - secret: reference/commands/secret.md + - source-path: reference/commands/source-path.md + - state: reference/commands/state.md + - status: reference/commands/status.md + - target-path: reference/commands/target-path.md + - unmanage: reference/commands/unmanage.md + - unmanaged: reference/commands/unmanaged.md + - update: reference/commands/update.md + - upgrade: reference/commands/upgrade.md + - verify: reference/commands/verify.md + - Templates: + - reference/templates/index.md + - Variables: reference/templates/variables.md + - Directives: reference/templates/directives.md + - Functions: + - reference/templates/functions/index.md + - comment: reference/templates/functions/comment.md + - completion: reference/templates/functions/completion.md + - decrypt: reference/templates/functions/decrypt.md + - deleteValueAtPath: reference/templates/functions/deleteValueAtPath.md + - encrypt: reference/templates/functions/encrypt.md + - eqFold: reference/templates/functions/eqFold.md + - findExecutable: reference/templates/functions/findExecutable.md + - findOneExecutable: reference/templates/functions/findOneExecutable.md + - fromIni: reference/templates/functions/fromIni.md + - fromJson: reference/templates/functions/fromJson.md + - fromJsonc: reference/templates/functions/fromJsonc.md + - fromToml: reference/templates/functions/fromToml.md + - fromYaml: reference/templates/functions/fromYaml.md + - glob: reference/templates/functions/glob.md + - hexDecode: reference/templates/functions/hexDecode.md + - hexEncode: reference/templates/functions/hexEncode.md + - include: reference/templates/functions/include.md + - includeTemplate: reference/templates/functions/includeTemplate.md + - ioreg: reference/templates/functions/ioreg.md + - isExecutable: reference/templates/functions/isExecutable.md + - joinPath: reference/templates/functions/joinPath.md + - jq: reference/templates/functions/jq.md + - lookPath: reference/templates/functions/lookPath.md + - lstat: reference/templates/functions/lstat.md + - mozillaInstallHash: reference/templates/functions/mozillaInstallHash.md + - output: reference/templates/functions/output.md + - pruneEmptyDicts: reference/templates/functions/pruneEmptyDicts.md + - quoteList: reference/templates/functions/quoteList.md + - replaceAllRegex: reference/templates/functions/replaceAllRegex.md + - setValueAtPath: reference/templates/functions/setValueAtPath.md + - stat: reference/templates/functions/stat.md + - toIni: reference/templates/functions/toIni.md + - toPrettyJson: reference/templates/functions/toPrettyJson.md + - toToml: reference/templates/functions/toToml.md + - toYaml: reference/templates/functions/toYaml.md + - GitHub functions: + - reference/templates/github-functions/index.md + - gitHubKeys: reference/templates/github-functions/gitHubKeys.md + - gitHubLatestRelease: reference/templates/github-functions/gitHubLatestRelease.md + - gitHubLatestReleaseAssetURL: reference/templates/github-functions/gitHubLatestReleaseAssetURL.md + - gitHubLatestTag: reference/templates/github-functions/gitHubLatestTag.md + - gitHubReleases: reference/templates/github-functions/gitHubReleases.md + - gitHubTags: reference/templates/github-functions/gitHubTags.md + - Init functions: + - reference/templates/init-functions/index.md + - exit: reference/templates/init-functions/exit.md + - promptBool: reference/templates/init-functions/promptBool.md + - promptBoolOnce: reference/templates/init-functions/promptBoolOnce.md + - promptChoice: reference/templates/init-functions/promptChoice.md + - promptChoiceOnce: reference/templates/init-functions/promptChoiceOnce.md + - promptInt: reference/templates/init-functions/promptInt.md + - promptIntOnce: reference/templates/init-functions/promptIntOnce.md + - promptString: reference/templates/init-functions/promptString.md + - promptStringOnce: reference/templates/init-functions/promptStringOnce.md + - stdinIsATTY: reference/templates/init-functions/stdinIsATTY.md + - writeToStdout: reference/templates/init-functions/writeToStdout.md + - 1Password functions: + - reference/templates/1password-functions/index.md + - onepassword: reference/templates/1password-functions/onepassword.md + - onepasswordDocument: reference/templates/1password-functions/onepasswordDocument.md + - onepasswordDetailsFields: reference/templates/1password-functions/onepasswordDetailsFields.md + - onepasswordItemFields: reference/templates/1password-functions/onepasswordItemFields.md + - onepasswordRead: reference/templates/1password-functions/onepasswordRead.md + - 1Password SDK functions: + - reference/templates/1password-sdk-functions/index.md + - onepasswordSDKItemsGet: reference/templates/1password-sdk-functions/onepasswordSDKItemsGet.md + - onepasswordSDKSecretsResolve: reference/templates/1password-sdk-functions/onepasswordSDKSecretsResolve.md + - AWS Secrets Manager functions: + - reference/templates/aws-secrets-manager-functions/index.md + - awsSecretsManager: reference/templates/aws-secrets-manager-functions/awsSecretsManager.md + - awsSecretsManagerRaw: reference/templates/aws-secrets-manager-functions/awsSecretsManagerRaw.md + - Azure Key Vault functions: + - azureKeyVault: reference/templates/azure-key-vault-functions/azureKeyVault.md + - Bitwarden functions: + - reference/templates/bitwarden-functions/index.md + - bitwarden: reference/templates/bitwarden-functions/bitwarden.md + - bitwardenAttachment: reference/templates/bitwarden-functions/bitwardenAttachment.md + - bitwardenAttachmentByRef: reference/templates/bitwarden-functions/bitwardenAttachmentByRef.md + - bitwardenFields: reference/templates/bitwarden-functions/bitwardenFields.md + - bitwardenSecrets: reference/templates/bitwarden-functions/bitwardenSecrets.md + - rbw: reference/templates/bitwarden-functions/rbw.md + - rbwFields: reference/templates/bitwarden-functions/rbwFields.md + - Dashlane functions: + - reference/templates/dashlane-functions/index.md + - dashlaneNote: reference/templates/dashlane-functions/dashlaneNote.md + - dashlanePassword: reference/templates/dashlane-functions/dashlanePassword.md + - Doppler functions: + - reference/templates/doppler-functions/index.md + - doppler: reference/templates/doppler-functions/doppler.md + - dopplerProjectJson: reference/templates/doppler-functions/dopplerProjectJson.md + - ejson functions: + - reference/templates/ejson-functions/index.md + - ejsonDecrypt: reference/templates/ejson-functions/ejsonDecrypt.md + - ejsonDecryptWithKey: reference/templates/ejson-functions/ejsonDecryptWithKey.md + - gopass functions: + - reference/templates/gopass-functions/index.md + - gopass: reference/templates/gopass-functions/gopass.md + - gopassRaw: reference/templates/gopass-functions/gopassRaw.md + - HCP Vault Secrets functions: + - reference/templates/hcp-vault-secrets-functions/index.md + - hcpVaultSecret: reference/templates/hcp-vault-secrets-functions/hcpVaultSecret.md + - hcpVaultSecretJson: reference/templates/hcp-vault-secrets-functions/hcpVaultSecretJson.md + - KeePassXC functions: + - reference/templates/keepassxc-functions/index.md + - keepassxc: reference/templates/keepassxc-functions/keepassxc.md + - keepassxcAttachment: reference/templates/keepassxc-functions/keepassxcAttachment.md + - keepassxcAttribute: reference/templates/keepassxc-functions/keepassxcAttribute.md + - Keeper functions: + - reference/templates/keeper-functions/index.md + - keeper: reference/templates/keeper-functions/keeper.md + - keeperDataFields: reference/templates/keeper-functions/keeperDataFields.md + - keeperFindPassword: reference/templates/keeper-functions/keeperFindPassword.md + - Keyring functions: + - keyring: reference/templates/keyring-functions/keyring.md + - LastPass functions: + - reference/templates/lastpass-functions/index.md + - lastpass: reference/templates/lastpass-functions/lastpass.md + - lastpassRaw: reference/templates/lastpass-functions/lastpassRaw.md + - pass functions: + - reference/templates/pass-functions/index.md + - pass: reference/templates/pass-functions/pass.md + - passFields: reference/templates/pass-functions/passFields.md + - passRaw: reference/templates/pass-functions/passRaw.md + - Passhole functions: + - reference/templates/passhole-functions/index.md + - passhole: reference/templates/passhole-functions/passhole.md + - Vault functions: + - vault: reference/templates/vault-functions/vault.md + - Generic secret functions: + - reference/templates/secret-functions/index.md + - secret: reference/templates/secret-functions/secret.md + - secretJSON: reference/templates/secret-functions/secretJSON.md + - Plugins: reference/plugins.md + - Release history: reference/release-history.md +- Developer guide: + - developer-guide/index.md + - Testing: developer-guide/testing.md + - Contributing changes: developer-guide/contributing-changes.md + - Website: developer-guide/website.md + - Install script: developer-guide/install-script.md + - Using make: developer-guide/using-make.md + - Releases: developer-guide/releases.md + - Packaging: developer-guide/packaging.md + - Security: developer-guide/security.md + - Architecture: developer-guide/architecture.md + - Building on top of chezmoi: developer-guide/building-on-top-of-chezmoi.md +- Links: + - Articles: links/articles.md + - Podcasts: links/podcasts.md + - Videos: links/videos.md + - Dotfile repos: links/dotfile-repos.md + - Related software: links/related-software.md + - Social media: links/social-media.md +- License: license.md + +markdown_extensions: +- admonition +- meta +- pymdownx.details +- pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:mermaid2.fence_mermaid +- pymdownx.tabbed: + alternate_style: true + +hooks: +- docs/hooks.py + +plugins: +- mermaid2: + arguments: + # test if its __palette_1 (dark) or __palette_2 (light) + theme: | + ^(JSON.parse(__md_get("__palette").index == 1)) ? 'dark' : 'light' +- search + +extra_javascript: +- extra/refresh_on_toggle_dark_light.js diff --git a/assets/chezmoi.io/requirements.txt b/assets/chezmoi.io/requirements.txt new file mode 100644 index 00000000000..d0e21ed2c95 --- /dev/null +++ b/assets/chezmoi.io/requirements.txt @@ -0,0 +1,3 @@ +mkdocs==1.6.0 +mkdocs-material==9.5.27 +mkdocs-mermaid2-plugin==1.1.1 diff --git a/assets/chezmoi.io/themes/book b/assets/chezmoi.io/themes/book deleted file mode 160000 index 8bb6d7ebec0..00000000000 --- a/assets/chezmoi.io/themes/book +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8bb6d7ebec03e232bf3544b25de8408b1a03b6fb diff --git a/assets/cosign/cosign.key b/assets/cosign/cosign.key new file mode 100644 index 00000000000..1f5885968e8 --- /dev/null +++ b/assets/cosign/cosign.key @@ -0,0 +1,11 @@ +-----BEGIN ENCRYPTED COSIGN PRIVATE KEY----- +eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjozMjc2OCwiciI6 +OCwicCI6MX0sInNhbHQiOiJ0Ris3cmtnL2RPZ0YxZzcwSTA4dVQ0aWdPYisxcTkv +SjRNaDIvd1RPQ2VNPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94 +Iiwibm9uY2UiOiJWMGpoNGxmenMyNFNRMEpyUExzeFNhbHZacGd5ZGJpbiJ9LCJj +aXBoZXJ0ZXh0IjoiTGMrTGsvQTRTdVBmblcyYWJnT2VFbnZDbkxNNG9QRnBFTnpZ +ZG04b1BVSlRSdmVsTWxJcjR4Slc1SjNJaXRLYlREOVFUMGRjZ1ozOE0zenh4WGNm +TVFxNFBmYXpsZHIwTHlFOE5LREx5bmtlMEcrT2ZwQ2RsbExhb1ZWWW5Uck9RWi9D +Y09Xanp4T3FXQ1VGWjlBK0UwVzJLSUJ2VHBjSHZNWVdDOVN3SDBwTUNja2dYc21p +SitOTzhYbmwwWTJ4T3N2My84SWJ4TXRzMmc9PSJ9 +-----END ENCRYPTED COSIGN PRIVATE KEY----- diff --git a/assets/cosign/cosign.pub b/assets/cosign/cosign.pub new file mode 100644 index 00000000000..6081834113c --- /dev/null +++ b/assets/cosign/cosign.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJDy2Dn3u5hqjQkTrcAukXwJty9Ke +oquP+qONwiD4r+cjO8yrhoELoUk1ogXzvpM7f9bOS/YS5pdx2snCmMudDg== +-----END PUBLIC KEY----- diff --git a/assets/docker/alpine.Dockerfile b/assets/docker/alpine.Dockerfile new file mode 100644 index 00000000000..2e985a30928 --- /dev/null +++ b/assets/docker/alpine.Dockerfile @@ -0,0 +1,6 @@ +FROM alpine:latest + +RUN apk --no-cache add age git go unzip zip + +COPY assets/docker/entrypoint.sh /entrypoint.sh +ENTRYPOINT /entrypoint.sh diff --git a/assets/docker/archlinux.Dockerfile b/assets/docker/archlinux.Dockerfile index 13d8db11972..836c8a6e400 100644 --- a/assets/docker/archlinux.Dockerfile +++ b/assets/docker/archlinux.Dockerfile @@ -1,4 +1,6 @@ FROM archlinux:latest -RUN pacman -Sy --noconfirm --noprogressbar age gcc git go unzip zip -ENTRYPOINT ( cd /chezmoi && go test ./... ) +RUN pacman -Syu --noconfirm age gcc git go unzip zip + +COPY assets/docker/entrypoint.sh /entrypoint.sh +ENTRYPOINT /entrypoint.sh diff --git a/assets/docker/entrypoint.sh b/assets/docker/entrypoint.sh new file mode 100755 index 00000000000..e469777d211 --- /dev/null +++ b/assets/docker/entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +set -euf + +git config --global --add safe.directory /chezmoi + +GO=${GO:-go} + +cd /chezmoi +${GO} run . doctor || true +${GO} test ./... + +sh assets/scripts/install.sh +bin/chezmoi --version diff --git a/assets/docker/fedora.Dockerfile b/assets/docker/fedora.Dockerfile index 623c931f036..66497ca631b 100644 --- a/assets/docker/fedora.Dockerfile +++ b/assets/docker/fedora.Dockerfile @@ -1,6 +1,9 @@ FROM fedora:latest +ENV GOPROXY=https://proxy.golang.org/ + RUN dnf update -y && \ dnf install -y bzip2 git gnupg golang -COPY assets/docker/fedora.entrypoint.sh /entrypoint.sh + +COPY assets/docker/entrypoint.sh /entrypoint.sh ENTRYPOINT /entrypoint.sh diff --git a/assets/docker/fedora.entrypoint.sh b/assets/docker/fedora.entrypoint.sh deleted file mode 100755 index a27de7948e0..00000000000 --- a/assets/docker/fedora.entrypoint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -eufo pipefail - -( cd /chezmoi && go test ./... ) diff --git a/assets/docker/test.sh b/assets/docker/test.sh index 43905b163e8..729c0065762 100755 --- a/assets/docker/test.sh +++ b/assets/docker/test.sh @@ -4,11 +4,19 @@ set -eufo pipefail cd ../.. for distribution in "$@"; do - dockerfile="assets/docker/${distribution}.Dockerfile" - if [ ! -f "${dockerfile}" ]; then - echo "${dockerfile} not found" - exit 1 - fi - image="$(docker build . -f "assets/docker/${distribution}.Dockerfile" -q)" - docker run --rm --volume "${PWD}:/chezmoi" "${image}" + echo "${distribution}" + dockerfile="assets/docker/${distribution}.Dockerfile" + if [ ! -f "${dockerfile}" ]; then + echo "${dockerfile} not found" + exit 1 + fi + image="$(docker build . -f "assets/docker/${distribution}.Dockerfile" -q)" + docker run \ + --env "CHEZMOI_GITHUB_ACCESS_TOKEN=${CHEZMOI_GITHUB_ACCESS_TOKEN-}" \ + --env "CHEZMOI_GITHUB_TOKEN=${CHEZMOI_GITHUB_TOKEN-}" \ + --env "GITHUB_ACCESS_TOKEN=${GITHUB_ACCESS_TOKEN-}" \ + --env "GITHUB_TOKEN=${GITHUB_TOKEN-}" \ + --rm \ + --volume "${PWD}:/chezmoi" \ + "${image}" done diff --git a/assets/docker/voidlinux.Dockerfile b/assets/docker/voidlinux.Dockerfile index 7948db8d884..e0d1f310c99 100644 --- a/assets/docker/voidlinux.Dockerfile +++ b/assets/docker/voidlinux.Dockerfile @@ -1,6 +1,10 @@ -FROM voidlinux/voidlinux:latest +FROM ghcr.io/void-linux/void-linux:20220211rc01-full-x86_64 RUN \ + xbps-install --sync --update --yes ; \ + xbps-install --update --yes xbps && \ xbps-install --sync --update --yes && \ - xbps-install --yes age gcc git go unzip zip -ENTRYPOINT ( cd /chezmoi && go test ./... ) + xbps-install --yes age curl gcc git go unzip zip + +COPY assets/docker/entrypoint.sh /entrypoint.sh +ENTRYPOINT /entrypoint.sh diff --git a/assets/get.chezmoi.io/.nojekyll b/assets/get.chezmoi.io/.nojekyll new file mode 100644 index 00000000000..e69de29bb2d diff --git a/assets/get.chezmoi.io/CNAME b/assets/get.chezmoi.io/CNAME new file mode 100644 index 00000000000..f86f0345fe7 --- /dev/null +++ b/assets/get.chezmoi.io/CNAME @@ -0,0 +1 @@ +get.chezmoi.io diff --git a/assets/images/144px.svg b/assets/images/144px.svg deleted file mode 100644 index 1f97a79a04d..00000000000 --- a/assets/images/144px.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/images/256px.png b/assets/images/256px.png deleted file mode 100644 index cef0f0b9a4c..00000000000 Binary files a/assets/images/256px.png and /dev/null differ diff --git a/assets/images/256px.svg b/assets/images/256px.svg deleted file mode 100644 index f9462142047..00000000000 --- a/assets/images/256px.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/images/512px.png b/assets/images/512px.png deleted file mode 100644 index d3de4f542e5..00000000000 Binary files a/assets/images/512px.png and /dev/null differ diff --git a/assets/images/512px.svg b/assets/images/512px.svg deleted file mode 100644 index a983a97e61a..00000000000 --- a/assets/images/512px.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/images/96px.svg b/assets/images/96px.svg deleted file mode 100644 index 35aa2b5ad5c..00000000000 --- a/assets/images/96px.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/requirements.dev.txt b/assets/requirements.dev.txt new file mode 100644 index 00000000000..38ed49bd376 --- /dev/null +++ b/assets/requirements.dev.txt @@ -0,0 +1 @@ +ruff==0.5.0 diff --git a/assets/requirements.txt b/assets/requirements.txt new file mode 100644 index 00000000000..4bfffaabe68 --- /dev/null +++ b/assets/requirements.txt @@ -0,0 +1 @@ +ruamel.yaml==0.18.6 diff --git a/assets/ruff.toml b/assets/ruff.toml new file mode 100644 index 00000000000..e9419148290 --- /dev/null +++ b/assets/ruff.toml @@ -0,0 +1,20 @@ +[lint] +extend-select = [ + "A", + "B", + "E", + "F", + "I", + "W", + "ANN", + "COM", + "FA", + "UP", + "PL", + "PTH", + "SIM", +] +ignore = ["ANN003"] + +[format] +quote-style = "single" diff --git a/assets/scripts/format-yaml.py b/assets/scripts/format-yaml.py new file mode 100755 index 00000000000..a3557abd756 --- /dev/null +++ b/assets/scripts/format-yaml.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import sys +from pathlib import Path + +from ruamel.yaml import YAML + + +def main() -> int: + yaml = YAML() + # ruamel.yaml.YAML will by default use the native line ending, which leads + # to differences between Windows and UNIX. Force the output to use UNIX line + # endings. + yaml.line_break = '\n' + # ruamel.yaml.YAML will by default break long lines and introduce trailing + # whitespace errors. Disable this behavior by setting a long line width. + yaml.width = 1024 + for filename in sys.argv[1:]: + with Path(filename).open('r') as file: + data = yaml.load(file) + with Path(filename).open('w', newline='\n') as file: + yaml.dump(data, file) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/assets/scripts/generate-commit.go b/assets/scripts/generate-commit.go new file mode 100644 index 00000000000..79f1d5bcaed --- /dev/null +++ b/assets/scripts/generate-commit.go @@ -0,0 +1,38 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "log" + "os" + "os/exec" + "strings" +) + +func main() { + output := flag.String("o", "", "output file") + flag.Parse() + + b := strings.Builder{} + sha, err := exec.Command("git", "rev-parse", "HEAD").Output() + if err != nil { + log.Fatal(err) + } + b.Write(bytes.TrimSpace(sha)) + + if err := exec.Command("git", "diff-index", "--quiet", "HEAD").Run(); err != nil { + b.WriteString("-dirty") + } + + commit := b.String() + + if *output != "" { + err := os.WriteFile(*output, []byte(commit), 0o644) + if err != nil { + log.Fatal(err) + } + } else { + fmt.Println(commit) + } +} diff --git a/assets/scripts/install-local-bin.sh b/assets/scripts/install-local-bin.sh new file mode 100644 index 00000000000..07d26ac9108 --- /dev/null +++ b/assets/scripts/install-local-bin.sh @@ -0,0 +1,354 @@ +#!/bin/sh + +# chezmoi install script +# contains code from and inspired by +# https://github.com/client9/shlib +# https://github.com/goreleaser/godownloader + +set -e + +BINDIR="${BINDIR:-.local/bin}" +CHEZMOI_USER_REPO="${CHEZMOI_USER_REPO:-twpayne/chezmoi}" +TAGARG=latest +LOG_LEVEL=2 + +GITHUB_DOWNLOAD="https://github.com/${CHEZMOI_USER_REPO}/releases/download" + +tmpdir="$(mktemp -d)" +trap 'rm -rf -- "${tmpdir}"' EXIT +trap 'exit' INT TERM + +usage() { + this="${1}" + cat <&2 + return 1 + ;; + esac +} + +get_libc() { + if is_command ldd; then + case "$(ldd --version 2>&1 | tr '[:upper:]' '[:lower:]')" in + *glibc* | *"gnu libc"*) + printf glibc + return + ;; + *musl*) + printf musl + return + ;; + esac + fi + if is_command getconf; then + case "$(getconf GNU_LIBC_VERSION 2>&1)" in + *glibc*) + printf glibc + return + ;; + esac + fi + log_crit "unable to determine libc" 1>&2 + exit 1 +} + +real_tag() { + tag="${1}" + log_debug "checking GitHub for tag ${tag}" + release_url="https://github.com/${CHEZMOI_USER_REPO}/releases/${tag}" + json="$(http_get "${release_url}" "Accept: application/json")" + if [ -z "${json}" ]; then + log_err "real_tag error retrieving GitHub release ${tag}" + return 1 + fi + real_tag="$(printf '%s\n' "${json}" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//')" + if [ -z "${real_tag}" ]; then + log_err "real_tag error determining real tag of GitHub release ${tag}" + return 1 + fi + if [ -z "${real_tag}" ]; then + return 1 + fi + log_debug "found tag ${real_tag} for ${tag}" + printf '%s' "${real_tag}" +} + +http_get() { + tmpfile="$(mktemp)" + http_download "${tmpfile}" "${1}" "${2}" || return 1 + body="$(cat "${tmpfile}")" + rm -f "${tmpfile}" + printf '%s\n' "${body}" +} + +http_download_curl() { + local_file="${1}" + source_url="${2}" + header="${3}" + if [ -z "${header}" ]; then + code="$(curl -w '%{http_code}' -sL -o "${local_file}" "${source_url}")" + else + code="$(curl -w '%{http_code}' -sL -H "${header}" -o "${local_file}" "${source_url}")" + fi + if [ "${code}" != "200" ]; then + log_debug "http_download_curl received HTTP status ${code}" + return 1 + fi + return 0 +} + +http_download_wget() { + local_file="${1}" + source_url="${2}" + header="${3}" + if [ -z "${header}" ]; then + wget -q -O "${local_file}" "${source_url}" || return 1 + else + wget -q --header "${header}" -O "${local_file}" "${source_url}" || return 1 + fi +} + +http_download() { + log_debug "http_download ${2}" + if is_command curl; then + http_download_curl "${@}" || return 1 + return + elif is_command wget; then + http_download_wget "${@}" || return 1 + return + fi + log_crit "http_download unable to find wget or curl" + return 1 +} + +hash_sha256() { + target="${1}" + if is_command sha256sum; then + hash="$(sha256sum "${target}")" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f 1 + elif is_command shasum; then + hash="$(shasum -a 256 "${target}" 2>/dev/null)" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f 1 + elif is_command sha256; then + hash="$(sha256 -q "${target}" 2>/dev/null)" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f 1 + elif is_command openssl; then + hash="$(openssl dgst -sha256 "${target}")" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f a + else + log_crit "hash_sha256 unable to find command to compute SHA256 hash" + return 1 + fi +} + +hash_sha256_verify() { + target="${1}" + checksums="${2}" + basename="${target##*/}" + + want="$(grep "${basename}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1)" + if [ -z "${want}" ]; then + log_err "hash_sha256_verify unable to find checksum for ${target} in ${checksums}" + return 1 + fi + + got="$(hash_sha256 "${target}")" + if [ "${want}" != "${got}" ]; then + log_err "hash_sha256_verify checksum for ${target} did not verify ${want} vs ${got}" + return 1 + fi +} + +untar() { + tarball="${1}" + case "${tarball}" in + *.tar.gz | *.tgz) tar -xzf "${tarball}" ;; + *.tar) tar -xf "${tarball}" ;; + *.zip) unzip -- "${tarball}" ;; + *) + log_err "untar unknown archive format for ${tarball}" + return 1 + ;; + esac +} + +is_command() { + type "${1}" >/dev/null 2>&1 +} + +log_debug() { + [ 3 -le "${LOG_LEVEL}" ] || return 0 + printf 'debug %s\n' "${*}" 1>&2 +} + +log_info() { + [ 2 -le "${LOG_LEVEL}" ] || return 0 + printf 'info %s\n' "${*}" 1>&2 +} + +log_err() { + [ 1 -le "${LOG_LEVEL}" ] || return 0 + printf 'error %s\n' "${*}" 1>&2 +} + +log_crit() { + [ 0 -le "${LOG_LEVEL}" ] || return 0 + printf 'critical %s\n' "${*}" 1>&2 +} + +main "${@}" diff --git a/assets/scripts/install.ps1 b/assets/scripts/install.ps1 index bf2254d8317..e66da29dc06 100644 --- a/assets/scripts/install.ps1 +++ b/assets/scripts/install.ps1 @@ -1,323 +1,269 @@ -enum LogLevel { - Debug = 3 - Info = 2 - Error = 1 - Critical = 0 -} +<# +.SYNOPSIS +Install (and optionally run) chezmoi. -$old_eap = $ErrorActionPreference -$ErrorActionPreference = 'Stop' # throw an exception if anything bad happens +.PARAMETER BinDir +Specifies the installation directory. "./bin" is the default. Alias: b -# If the environment isn't correct for running this script, try to give people -# some idea of how to fix it +.PARAMETER Tag +Specifies the version of chezmoi to install. "latest" is the default. Alias: t -if (($PSVersionTable.PSVersion.Major) -lt 5) { - Write-Warning "PowerShell 5 or later is required to run this install script." - Write-Warning "Please upgrade: https://docs.microsoft.com/en-us/powershell/scripting/setup/installing-windows-powershell" - break -} +.PARAMETER EnableDebug +If specified, print debug output. Alias: d -# show notification to change execution policy: -$allowedExecutionPolicy = @('Unrestricted', 'RemoteSigned', 'Bypass') -if ((Get-ExecutionPolicy).ToString() -notin $allowedExecutionPolicy) { - Write-Warning "PowerShell requires an execution policy in [$($allowedExecutionPolicy -join ", ")] to run this install script." - Write-Warning "For example, to set the execution policy to 'RemoteSigned' please run :" - Write-Warning "'Set-ExecutionPolicy RemoteSigned -scope CurrentUser'" - break -} +.PARAMETER ChezmoiArgs +If specified, execute chezmoi with these arguments after successful installation. This parameter can be provided +positionally without specifying its name. -# globals -$tempdir = (Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName())); - -# Helper functions -function Fetch-FileFromWeb( - [string]$url, - [string]$path -) { - $cl = New-Object Net.WebClient - $cl.Headers['User-Agent'] = 'System.Net.WebClient' - $cl.DownloadFile($url, $path) -} +.EXAMPLE +PS> install.ps1 -b '~/bin' +PS> iex "&{$(irm 'https://get.chezmoi.io/ps1')} -b '~/bin'" -function Fetch-StringFromWeb( - [string]$url -) { - $cl = New-Object Net.WebClient - $cl.Headers['User-Agent'] = 'System.Net.WebClient' - return $cl.DownloadString($url) +.EXAMPLE +PS> install.ps1 -- init --apply +PS> iex "&{$(irm 'https://get.chezmoi.io/ps1')} -- init --apply " +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory = $false)] + [Alias('b')] + [string] + $BinDir = (Join-Path -Path (Resolve-Path -Path '.') -ChildPath 'bin'), + + [Parameter(Mandatory = $false)] + [Alias('t')] + [string] + $Tag = 'latest', + + [Parameter(Mandatory = $false)] + [Alias('d')] + [switch] + $EnableDebug, + + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]] + $ChezmoiArgs +) + +function Write-DebugVariable { + param ( + [string[]]$variables + ) + foreach ($variable in $variables) { + $debugVariable = Get-Variable -Name $variable + Write-Debug "$( $debugVariable.Name ): $( $debugVariable.Value )" + } } -function Fetch-JsonFromWeb( - [string]$url -) { - $cl = New-Object Net.WebClient - $cl.Headers['User-Agent'] = 'System.Net.WebClient' - $cl.Headers['Accept'] = 'application/json' - return $cl.DownloadString($url) | ConvertFrom-Json +function Invoke-CleanUp ($directory) { + if (($null -ne $directory) -and (Test-Path -Path $directory)) { + Write-Debug "removing ${directory}" + Remove-Item -Path $directory -Recurse -Force + } } -function Fetch-DataFromWeb( - [string]$url -) { - $cl = New-Object Net.WebClient - $cl.Headers['User-Agent'] = 'System.Net.WebClient' - return $cl.DownloadData($url) +function Invoke-FileDownload ($uri, $path) { + Write-Debug "downloading ${uri}" + $wc = [System.Net.WebClient]::new() + $wc.Headers.Add('Accept', 'application/octet-stream') + $wc.DownloadFile($uri, $path) + $wc.Dispose() } -function log { - [CmdletBinding(PositionalBinding=$false)] - param( - [LogLevel] $MessageLevel, - [string] $Message - ) +function Invoke-StringDownload ($uri) { + Write-Debug "downloading ${uri} as string" + $wc = [System.Net.WebClient]::new() + $wc.DownloadString($uri) + $wc.Dispose() +} - if ([int]$LogLevel -ge [int]$MesageLevel) { - Write-Host $Message +function Get-GoOS { + if ($PSVersionTable.PSEdition -eq 'Desktop') { + return 'windows' } -} -function log-debug { - param( - [string] $Message - ) - log -MessageLevel Debug -Message $Message -} + $isOSPlatform = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform + $osPlatform = [System.Runtime.InteropServices.OSPlatform] -function log-info { - param( - [string] $Message - ) - log -MessageLevel Info -Message $Message -} + if ($isOSPlatform.Invoke($osPlatform::Windows)) { return 'windows' } + if ($isOSPlatform.Invoke($osPlatform::Linux)) { return 'linux' } + if ($isOSPlatform.Invoke($osPlatform::OSX)) { return 'darwin' } -function log-error { - param( - [string] $Message - ) - log -MessageLevel Error -Message $Message + Write-Error 'unable to determine GOOS' } -function log-critical { - param( - [string] $Message - ) - log -MessageLevel Critical -Message $Message -} - -function get_goos { - $ri = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform; - # if $ri isn't defined, then we must be running in Powershell 5.1, which only works on Windows. - if (-not $ri -or $ri.Invoke([Runtime.InteropServices.OSPlatform]::Windows)) { - return "windows" - } elseif ($ri.Invoke([Runtime.InteropServices.OSPlatform]::Linux)) { - return "linux" - } elseif ($ri.Invoke([Runtime.InteropServices.OSPlatform]::OSX)) { - return "darwin" - } elseif ($ri.Invoke([Runtime.InteropServices.OSPlatform]::FreeBSD)) { - return "freebsd" - } else { - throw "unsupported platform" +function Get-GoArch { + $goArch = @{ + '32-bit' = 'i386' + '64-bit' = 'amd64' + 'Arm' = 'arm' + 'Arm64' = 'arm64' + 'X86' = 'i386' + 'X64' = 'amd64' } -} -function get_goarch { - $arch = "$([Runtime.InteropServices.RuntimeInformation]::OSArchitecture)"; - $wmi_arch = $null; + $arch = $null - if ((-not $arch) -and [Environment]::Is64BitOperatingSystem) { - $arch = "X64"; - } + if ($PSVersionTable.PSEdition -eq 'Desktop') { + $arch = (Get-CimInstance -Class Win32_OperatingSystem).OSArchitecture + } else { + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() - # [Environment]::Is64BitOperatingSystem is only available on .net 4 or - # newer, so if we still don't know, try another method - if (-not $arch) { - if (Get-Command "Get-WmiObject" -ErrorAction SilentlyContinue) { - $wmi_arch = (Get-WmiObject -Class Win32_OperatingSystem | Select-Object *).OSArchitecture - if ($wmi_arch.StartsWith("64")) { - $arch = "X64"; - } elseif ($wmi_arch.StartsWith("32")) { - $arch = "X86"; + if ([string]::IsNullOrEmpty($arch)) { + $arch = if ([System.Environment]::Is64BitOperatingSystem) { + 'X64' + } else { + 'X86' } } } - switch ($arch) { - "Arm" { - return "arm" - } - "Arm64" { - return "arm64" - } - "X86" { - return "i386" - } - "X64" { - return "amd64" - } - - default { - log-debug "Unsupported architecture: $arch (wmi: $wmi_arch)" - throw "unsupported architecture" - } + if ([string]::IsNullOrEmpty($arch)) { + Write-Error 'unable to determine GOARCH' } -} - -function get-real-tag { - param( - [string] $tag - ) - - log-debug "checking GitHub for tag $tag" - - $release_url = "https://github.com/twpayne/chezmoi/releases/$tag" - $real_tag = (Fetch-JsonFromWeb $release_url).tag_name - log-debug "found tag $real_tag for $tag" - return $real_tag + return $goArch[$arch] } -function verify-hash { - param( - [string] $target, - [string] $checksums - ) +function Get-RealTag ($tag) { + Write-Debug "checking GitHub for tag ${tag}" + $releaseUrl = "${BaseUrl}/${tag}" + $json = try { + Invoke-RestMethod -Uri $releaseUrl -Headers @{ 'Accept' = 'application/json' } + } catch { + Write-Error "error retrieving GitHub release ${tag}" + } + $realTag = $json.tag_name + Write-Debug "found tag ${realTag} for ${tag}" + return $realTag +} - $basename = [IO.Path]::GetFileName($target); +function Get-LibC { + $libcOutput = '' + if (Get-Command -CommandType Application ldd -ErrorAction SilentlyContinue) { + $libcOutput = (ldd --version 2>&1) -join [System.Environment]::NewLine + } elseif (Get-Command -CommandType Application getconf -ErrorAction SilentlyContinue) { + $libcOutput = (getconf GNU_LIBC_VERSION 2>&1) -join [System.Environment]::NewLine + } + Write-DebugVariable 'libcOutput' + switch -Wildcard ($libcOutput) { + '*glibc*' { return 'glibc' } + '*gnu libc*' { return 'glibc' } + '*musl*' { return 'musl' } + } + Write-Error 'unable to determine libc' +} - # what checksum are we looking for? - $want = (Get-Content $checksums | ForEach-Object { - $line = $_; - if ($line -match "$($basename)$") { - $hash, $name = ($line -split "\s+"); - return $hash; - } - } | Select-Object -First 1).ToLower(); +function Get-Checksums ($tag, $version) { + $checksumsText = Invoke-StringDownload "${BaseUrl}/download/${tag}/chezmoi_${version}_checksums.txt" - $got = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLower(); + $checksums = @{} + $lines = $checksumsText -split '\r?\n' | Where-Object { $_ } + foreach ($line in $lines) { + $value, $key = $line -split '\s+' + $checksums[$key] = $value + } + $checksums +} +function Confirm-Checksum ($target, $checksums) { + $basename = [System.IO.Path]::GetFileName($target) + if (-not $checksums.ContainsKey($basename)) { + Write-Error "unable to find checksum for ${target} in checksums" + } + $want = $checksums[$basename].ToLower() + $got = (Get-FileHash -Path $target -Algorithm SHA256).Hash.ToLower() if ($want -ne $got) { - Write-Error "Wanted: $want" - Write-Error "Got: $got" - throw "Checksum mismatch!" + Write-Error "checksum for ${target} did not verify ${want} vs ${got}" } } -function unpack-file { - param( - [string] $file - ) - - if ($file.EndsWith(".tar.gz") -or $file.EndsWith(".tgz")) { - tar -xzf $file - } elseif ($file.EndsWith(".tar")) { - tar -xf $file - } elseif ($file.EndsWith(".zip")) { - Expand-Archive -LiteralPath $file -DestinationPath . - } else { - throw "can't unpack unknown format for $file" +function Expand-ChezmoiArchive ($path) { + $parent = Split-Path -Path $path -Parent + Write-Debug "extracting ${path} to ${parent}" + if ($path.EndsWith('.tar.gz')) { + & tar --extract --gzip --file $path --directory $parent + } + if ($path.EndsWith('.zip')) { + Expand-Archive -Path $path -DestinationPath $parent } } -<# - .SYNOPSIS - Install the Chezmoi dotfile manager - - .DESCRIPTION - Installs Chezmoi to the given directory, defaulting to ./bin - - You can specify a particular git tag using the -Tag option. - - Examples: - '$params = "-BinDir ~/bindir"', (iwr https://git.io/chezmoi.ps1).Content | powershell -c - - '$params = "-Tag v1.8.10"', (iwr https://git.io/chezmoi.ps1).Content | powershell -c - -#> -function Install-Chezmoi { - [CmdletBinding(PositionalBinding=$false)] - param( - [Parameter(Mandatory = $false)] - [string] - $BinDir = (Join-Path (Resolve-Path '.') 'bin'), +Set-StrictMode -Version 3.0 - [Parameter(Mandatory = $false)] - [string] $Tag = 'latest', +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 - [LogLevel] $LogLevel = [LogLevel]::Info, +$script:ErrorActionPreference = 'Stop' +$script:InformationPreference = 'Continue' +if ($EnableDebug) { + $script:DebugPreference = 'Continue' +} - [Parameter(ValueFromRemainingArguments = $true)] - [string[]]$ExecArgs - ) +trap { + Invoke-CleanUp $tempDir + break +} - # some sub-functions (ie, get_goarch, likely others) require fetching of - # non-existent properites to not error - Set-StrictMode -off +$BaseUrl = 'https://github.com/twpayne/chezmoi/releases' - # $BinDir = Resolve-Path $BinDir +# convert $BinDir to an absolute path +$BinDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($BinDir) - $os = get_goos - $arch = get_goarch - $real_tag = get-real-tag $Tag - $version = if ($real_tag.StartsWith("v")) { $real_tag.Substring(1) } else { $real_tag }; +$tempDir = '' +do { + $tempDir = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ([System.Guid]::NewGuid()) +} while (Test-Path -Path $tempDir) +New-Item -ItemType Directory -Path $tempDir | Out-Null - log-info "found version $version for $Tag/$os/$arch" +Write-DebugVariable 'BinDir', 'Tag', 'ChezmoiArgs', 'tempDir' - $binsuffix = "" - $format = "tar.gz" +$goOS = Get-GoOS +$goArch = Get-GoArch +foreach ($variableName in @('goOS', 'goArch')) { + Write-DebugVariable $variableName +} - if ($os -eq "windows") { - $binsuffix = ".exe" - $format = "zip" +$realTag = Get-RealTag $Tag +$version = $realTag.TrimStart('v') +Write-Information "found version ${version} for ${Tag}/${goOS}/${goArch}" + +$binarySuffix = '' +$archiveFormat = 'tar.gz' +$goOSExtra = '' +switch ($goOS) { + 'linux' { + $goOSExtra = "-$( Get-LibC )" + break } - - $github_download = "https://github.com/twpayne/chezmoi/releases/download" - New-Item -Type Directory -Path $tempdir | Out-Null - - # download tarball - $name="chezmoi_$($version)_$($os)_$($arch)" - $tarball="$name.$format" - $tarball_url="$($github_download)/$real_tag/$tarball" - - $tmp_tarball = (Join-Path $tempdir $tarball) - Fetch-FileFromWeb $tarball_url $tmp_tarball - - # download checksums - $checksums = "chezmoi_$($version)_checksums.txt" - $checksums_url = "$($github_download)/$($real_tag)/$($checksums)" - - $tmp_checksums = (Join-Path $tempdir $checksums) - Fetch-FileFromWeb $checksums_url $tmp_checksums - - # verify checksums - verify-hash $tmp_tarball $tmp_checksums - - Push-Location $tempdir - - unpack-file $tarball - - # install the binary - if (-not (Test-Path $BinDir)) { - New-Item -Type Directory -Path $BinDir | Out-Null + 'windows' { + $binarySuffix = '.exe' + $archiveFormat = 'zip' + break } +} +Write-DebugVariable 'binarySuffix', 'archiveFormat', 'goOSExtra' - $binary = "chezmoi$($binsuffix)"; - $tmp_binary = (Join-Path $tempdir $binary); +$archiveFilename = "chezmoi_${version}_${goOS}${goOSExtra}_${goArch}.${archiveFormat}" +$tempArchivePath = Join-Path -Path $tempDir -ChildPath $archiveFilename +Write-DebugVariable 'archiveFilename', 'tempArchivePath' +Invoke-FileDownload "${BaseUrl}/download/${realTag}/${archiveFilename}" $tempArchivePath - Move-Item -Force -Path $tmp_binary -Destination $BinDir +$checksums = Get-Checksums $realTag $version +Confirm-Checksum $tempArchivePath $checksums - log-info "Installed $($BinDir)/$($binary)" +Expand-ChezmoiArchive $tempArchivePath - if ($ExecArgs) { - & "$($BinDir)/$($binary)" $ExecArgs - } -} +$binaryFilename = "chezmoi${binarySuffix}" +$tempBinaryPath = Join-Path -Path $tempDir -ChildPath $binaryFilename +Write-DebugVariable 'binaryFilename', 'tempBinaryPath' +[System.IO.Directory]::CreateDirectory($BinDir) | Out-Null +$binary = Join-Path -Path $BinDir -ChildPath $binaryFilename +Write-DebugVariable 'binary' +Move-Item -Path $tempBinaryPath -Destination $binary -Force +Write-Information "installed ${binary}" -try { - Invoke-Expression ("Install-Chezmoi " + $params) -} catch { - Write-Host "An error occurred while installing: $_" -} finally { - Pop-Location +Invoke-CleanUp $tempDir - if (Test-Path $tempdir) { - Remove-Item -LiteralPath $tempdir -Recurse -Force - } +if (($null -ne $ChezmoiArgs) -and ($ChezmoiArgs.Count -gt 0)) { + & $binary $ChezmoiArgs } diff --git a/assets/scripts/install.sh b/assets/scripts/install.sh index daf990bc815..7f5c58e7006 100644 --- a/assets/scripts/install.sh +++ b/assets/scripts/install.sh @@ -7,38 +7,40 @@ set -e -BINDIR=${BINDIR:-./bin} +BINDIR="${BINDIR:-./bin}" +CHEZMOI_USER_REPO="${CHEZMOI_USER_REPO:-twpayne/chezmoi}" TAGARG=latest LOG_LEVEL=2 -EXECARGS= -GITHUB_DOWNLOAD=https://github.com/twpayne/chezmoi/releases/download +GITHUB_DOWNLOAD="https://github.com/${CHEZMOI_USER_REPO}/releases/download" -tmpdir=$(mktemp -d) -trap 'rm -rf ${tmpdir}' EXIT +tmpdir="$(mktemp -d)" +trap 'rm -rf -- "${tmpdir}"' EXIT +trap 'exit' INT TERM usage() { - this="$1" + this="${1}" cat <&2 + printf '%s: unsupported platform\n' "${1}" 1>&2 return 1 ;; esac @@ -176,12 +185,12 @@ check_goos_goarch() { get_libc() { if is_command ldd; then case "$(ldd --version 2>&1 | tr '[:upper:]' '[:lower:]')" in - *glibc*|"*gnu libc*") - echo glibc + *glibc* | *"gnu libc"*) + printf glibc return ;; *musl*) - echo musl + printf musl return ;; esac @@ -189,7 +198,7 @@ get_libc() { if is_command getconf; then case "$(getconf GNU_LIBC_VERSION 2>&1)" in *glibc*) - echo glibc + printf glibc return ;; esac @@ -199,40 +208,42 @@ get_libc() { } real_tag() { - tag=$1 + tag="${1}" log_debug "checking GitHub for tag ${tag}" - release_url="https://github.com/twpayne/chezmoi/releases/${tag}" - json=$(http_get "${release_url}" "Accept: application/json") + release_url="https://github.com/${CHEZMOI_USER_REPO}/releases/${tag}" + json="$(http_get "${release_url}" "Accept: application/json")" if [ -z "${json}" ]; then log_err "real_tag error retrieving GitHub release ${tag}" return 1 fi - real_tag=$(echo "${json}" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//') + real_tag="$(printf '%s\n' "${json}" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//')" if [ -z "${real_tag}" ]; then log_err "real_tag error determining real tag of GitHub release ${tag}" return 1 fi - test -z "${real_tag}" && return 1 + if [ -z "${real_tag}" ]; then + return 1 + fi log_debug "found tag ${real_tag} for ${tag}" - echo "${real_tag}" + printf '%s' "${real_tag}" } http_get() { - tmpfile=$(mktemp) - http_download "${tmpfile}" "$1" "$2" || return 1 - body=$(cat "${tmpfile}") + tmpfile="$(mktemp)" + http_download "${tmpfile}" "${1}" "${2}" || return 1 + body="$(cat "${tmpfile}")" rm -f "${tmpfile}" - echo "${body}" + printf '%s\n' "${body}" } http_download_curl() { - local_file=$1 - source_url=$2 - header=$3 + local_file="${1}" + source_url="${2}" + header="${3}" if [ -z "${header}" ]; then - code=$(curl -w '%{http_code}' -sL -o "${local_file}" "${source_url}") + code="$(curl -w '%{http_code}' -sL -o "${local_file}" "${source_url}")" else - code=$(curl -w '%{http_code}' -sL -H "${header}" -o "${local_file}" "${source_url}") + code="$(curl -w '%{http_code}' -sL -H "${header}" -o "${local_file}" "${source_url}")" fi if [ "${code}" != "200" ]; then log_debug "http_download_curl received HTTP status ${code}" @@ -242,9 +253,9 @@ http_download_curl() { } http_download_wget() { - local_file=$1 - source_url=$2 - header=$3 + local_file="${1}" + source_url="${2}" + header="${3}" if [ -z "${header}" ]; then wget -q -O "${local_file}" "${source_url}" || return 1 else @@ -253,12 +264,12 @@ http_download_wget() { } http_download() { - log_debug "http_download $2" + log_debug "http_download ${2}" if is_command curl; then - http_download_curl "$@" || return 1 + http_download_curl "${@}" || return 1 return elif is_command wget; then - http_download_wget "$@" || return 1 + http_download_wget "${@}" || return 1 return fi log_crit "http_download unable to find wget or curl" @@ -266,19 +277,19 @@ http_download() { } hash_sha256() { - target=$1 + target="${1}" if is_command sha256sum; then - hash=$(sha256sum "${target}") || return 1 - echo "${hash}" | cut -d ' ' -f 1 + hash="$(sha256sum "${target}")" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f 1 elif is_command shasum; then - hash=$(shasum -a 256 "${target}" 2>/dev/null) || return 1 - echo "${hash}" | cut -d ' ' -f 1 + hash="$(shasum -a 256 "${target}" 2>/dev/null)" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f 1 elif is_command sha256; then - hash=$(sha256 -q "${target}" 2>/dev/null) || return 1 - echo "${hash}" | cut -d ' ' -f 1 + hash="$(sha256 -q "${target}" 2>/dev/null)" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f 1 elif is_command openssl; then - hash=$(openssl dgst -sha256 "${target}") || return 1 - echo "${hash}" | cut -d ' ' -f a + hash="$(openssl dgst -sha256 "${target}")" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f a else log_crit "hash_sha256 unable to find command to compute SHA256 hash" return 1 @@ -286,17 +297,17 @@ hash_sha256() { } hash_sha256_verify() { - target=$1 - checksums=$2 - basename=${target##*/} + target="${1}" + checksums="${2}" + basename="${target##*/}" - want=$(grep "${basename}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) + want="$(grep "${basename}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1)" if [ -z "${want}" ]; then log_err "hash_sha256_verify unable to find checksum for ${target} in ${checksums}" return 1 fi - got=$(hash_sha256 "${target}") + got="$(hash_sha256 "${target}")" if [ "${want}" != "${got}" ]; then log_err "hash_sha256_verify checksum for ${target} did not verify ${want} vs ${got}" return 1 @@ -304,11 +315,11 @@ hash_sha256_verify() { } untar() { - tarball=$1 + tarball="${1}" case "${tarball}" in *.tar.gz | *.tgz) tar -xzf "${tarball}" ;; *.tar) tar -xf "${tarball}" ;; - *.zip) unzip "${tarball}" ;; + *.zip) unzip -- "${tarball}" ;; *) log_err "untar unknown archive format for ${tarball}" return 1 @@ -317,27 +328,27 @@ untar() { } is_command() { - command -v "$1" >/dev/null + type "${1}" >/dev/null 2>&1 } log_debug() { [ 3 -le "${LOG_LEVEL}" ] || return 0 - echo debug "$@" 1>&2 + printf 'debug %s\n' "${*}" 1>&2 } log_info() { [ 2 -le "${LOG_LEVEL}" ] || return 0 - echo info "$@" 1>&2 + printf 'info %s\n' "${*}" 1>&2 } log_err() { [ 1 -le "${LOG_LEVEL}" ] || return 0 - echo error "$@" 1>&2 + printf 'error %s\n' "${*}" 1>&2 } log_crit() { [ 0 -le "${LOG_LEVEL}" ] || return 0 - echo critical "$@" 1>&2 + printf 'critical %s\n' "${*}" 1>&2 } -main "$@" +main "${@}" diff --git a/assets/scripts/stow-to-chezmoi.sh b/assets/scripts/stow-to-chezmoi.sh index cb797465d7b..c84648b6e4d 100755 --- a/assets/scripts/stow-to-chezmoi.sh +++ b/assets/scripts/stow-to-chezmoi.sh @@ -1,29 +1,31 @@ -#!/usr/bin/env bash +#!/bin/sh set -e -BASEDIR="${1:-$HOME}" +BASEDIR="${1:-${HOME}}" STOWDIR="${2:-dotfiles}" BASEDIR="$( - unset CDPATH - cd "${BASEDIR}" >/dev/null 2>&1 - pwd + unset -v CDPATH + cd -- "${BASEDIR}" >/dev/null 2>&1 + pwd && printf . )" +BASEDIR="${BASEDIR%??}" # if we have greadlink, use that -READLINK="$(which greadlink 2>/dev/null || which readlink)" +READLINK="$(command -v greadlink 2>/dev/null || command -v readlink)" removelink() { - [ -L "$1" ] && ( - LINK_DEST="$($READLINK -f "$1")" - rm "$1" - echo -ne "${LINK_DEST} ==> $1\t" - if cp -R "${LINK_DEST}" "$1"; then - echo "Done" + [ -h "${1}" ] && ( + LINK_DEST="$("${READLINK}" -f -- "${1}" && printf .)" + LINK_DEST="${LINK_DEST%??}" + rm -- "${1}" + printf '%s ==> %s\t' "${LINK_DEST}" "${1}" >&2 + if cp -r -- "${LINK_DEST}" "${1}"; then + printf 'Done\n' >&2 else - echo "FAILED" - return 1 + printf 'FAILED\n' >&2 + exit 1 fi ) } @@ -31,32 +33,37 @@ removelink() { work_file="$(mktemp)" act_file="$(mktemp)" -trap 'rm -f ${work_file} ${act_file}' EXIT +# attempt to clean up temporary files on exit +trap 'rm -f -- "${work_file}" "${act_file}"' EXIT +trap 'exit' INT TERM -find "${BASEDIR}" -not -path "${BASEDIR}/${STOWDIR}*" -type l >"${work_file}" || echo "Find skipped some files" +find "${BASEDIR}" \! -path '* +*' \! -path "${BASEDIR}/${STOWDIR}*" -type l >"${work_file}" || + printf "Find skipped some files\n" >&2 while read -r f; do - target="$($READLINK -f "${f}" || echo '')" - if [[ "${target}" == "${BASEDIR}/${STOWDIR}/"* ]]; then - echo "Add $f" - echo "${f}" >>"${act_file}" - fi + target="$("${READLINK}" -f -- "${f}" || :)" + case "${target}" in + "${BASEDIR}/${STOWDIR}/"*) + printf 'Add %s\n' "${f}" >&2 + printf '%s\n' "${f}" >>"${act_file}" + ;; + esac done <"${work_file}" -read -p "Migrate the above to chezmoi? y/N" -r migrate +printf 'Migrate the above to chezmoi? y/N ' >&2 +read -r migrate case "${migrate}" in -[Yy]*) - echo "Migrating..." - ;; +[Yy]*) printf 'Migrating...\n' >&2 ;; *) exit 1 ;; esac -mkdir -p "${BASEDIR}/.local/share" +mkdir -p -- "${BASEDIR}/.local/share" while read -r f; do if removelink "${f}"; then - chezmoi --source "${BASEDIR}/.local/share/chezmoi" --destination "${BASEDIR}" add "${f}" + chezmoi --source "${BASEDIR}/.local/share/chezmoi" --destination "${BASEDIR}" add -- "${f}" else - echo "Unable to move: $f" + printf 'Unable to move: %s\n' "${f}" >&2 fi done <"${act_file}" diff --git a/assets/templates/COMMIT_MESSAGE.tmpl b/assets/templates/COMMIT_MESSAGE.tmpl index ece3641ba6f..244e89b38da 100644 --- a/assets/templates/COMMIT_MESSAGE.tmpl +++ b/assets/templates/COMMIT_MESSAGE.tmpl @@ -1,13 +1,16 @@ +{{- with .chezmoi.status }} + {{- range .Ordinary -}} -{{ if and (eq .X 'A') (eq .Y '.') -}}Add {{ .Path }} -{{ else if and (eq .X 'D') (eq .Y '.') -}}Remove {{ .Path }} -{{ else if and (eq .X 'M') (eq .Y '.') -}}Update {{ .Path }} +{{ if and (eq .X 'A') (eq .Y '.') -}}Add {{ .Path | targetRelPath }} +{{ else if and (eq .X 'D') (eq .Y '.') -}}Remove {{ .Path | targetRelPath }} +{{ else if and (eq .X 'M') (eq .Y '.') -}}Update {{ .Path | targetRelPath }} {{ else }}{{with (printf "unsupported XY: %q" (printf "%c%c" .X .Y)) }}{{ fail . }}{{ end }} {{ end }} {{- end -}} {{- range .RenamedOrCopied -}} -{{ if and (eq .X 'R') (eq .Y '.') }}Rename {{ .OrigPath }} to {{ .Path }} +{{ if and (eq .X 'R') (eq .Y '.') }}Change attributes of {{ .Path | targetRelPath }} +{{ else if and (eq .X 'C') (eq .Y '.') -}}Copy {{ .OrigPath | targetRelPath }} to {{ .Path | targetRelPath }} {{ else }}{{with (printf "unsupported XY: %q" (printf "%c%c" .X .Y)) }}{{ fail . }}{{ end }} {{ end }} {{- end -}} @@ -19,3 +22,5 @@ {{- range .Untracked -}} {{ fail "untracked files" }} {{- end -}} + +{{- end -}} diff --git a/assets/templates/install.sh b/assets/templates/install.sh new file mode 100644 index 00000000000..46995f9d9bc --- /dev/null +++ b/assets/templates/install.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +# -e: exit on error +# -u: exit on unset variables +set -eu + +if ! chezmoi="$(command -v chezmoi)"; then + bin_dir="${HOME}/.local/bin" + chezmoi="${bin_dir}/chezmoi" + echo "Installing chezmoi to '${chezmoi}'" >&2 + if command -v curl >/dev/null; then + chezmoi_install_script="$(curl -fsSL get.chezmoi.io)" + elif command -v wget >/dev/null; then + chezmoi_install_script="$(wget -qO- get.chezmoi.io)" + else + echo "To install chezmoi, you must have curl or wget installed." >&2 + exit 1 + fi + sh -c "${chezmoi_install_script}" -- -b "${bin_dir}" + unset chezmoi_install_script bin_dir +fi + +# POSIX way to get script's dir: https://stackoverflow.com/a/29834779/12156188 +script_dir="$(cd -P -- "$(dirname -- "$(command -v -- "$0")")" && pwd -P)" + +set -- init --apply --source="${script_dir}" + +echo "Running 'chezmoi $*'" >&2 +# exec: replace current process with chezmoi +exec "$chezmoi" "$@" diff --git a/assets/templates/templates.go b/assets/templates/templates.go index a3c900004fc..804a8288217 100644 --- a/assets/templates/templates.go +++ b/assets/templates/templates.go @@ -1,8 +1,10 @@ // Package templates contains chezmoi's templates. package templates -import "embed" +import _ "embed" -// FS contains all templates. -//go:embed *.tmpl -var FS embed.FS +//go:embed COMMIT_MESSAGE.tmpl +var CommitMessageTmpl string + +//go:embed install.sh +var InstallSH []byte diff --git a/assets/templates/versioninfo.json.tmpl b/assets/templates/versioninfo.json.tmpl new file mode 100644 index 00000000000..c1ffe2a2836 --- /dev/null +++ b/assets/templates/versioninfo.json.tmpl @@ -0,0 +1,35 @@ +{{- $name := "chezmoi" -}} +{{- $filename := printf "%s.exe" $name -}} + +{{- $comments := "" -}} +{{- if exists ".git" -}} +{{- $commitHash := output "git" "rev-parse" "HEAD" | trim -}} +{{- if ne "" (output "git" "diff" "--stat" | trim) -}} +{{- $commitHash = printf "%s-dirty" $commitHash -}} +{{- end -}} +{{- $comments = printf "commit: %s" $commitHash -}} +{{- end -}} + +{{- $versionStr := "v0.0.0" -}} +{{- if and (exists ".git") (output "git" "tag" "--list" | trim) -}} +{{- $versionStr = output "git" "describe" "--abbrev=0" "--tags" | trim -}} +{{- end -}} +{{- $versionDict := dict -}} +{{- with semver $versionStr -}} +{{- $versionDict = (dict "Major" .Major "Minor" .Minor "Patch" .Patch "Build" 0) -}} +{{- end -}} + +{{- dict + "FixedFileInfo" (dict + "FileVersion" $versionDict + "ProductVersion" $versionDict) + "StringFileInfo" (dict + "Comments" $comments + "FileDescription" "Manage your dotfiles across multiple diverse machines, securely." + "FileVersion" $versionStr + "InternalName" $filename + "LegalCopyright" (printf "Copyright (c) 2018-%s Tom Payne" (now | date "2006")) + "OriginalFilename" $filename + "ProductName" $name + "ProductVersion" $versionStr) + | toPrettyJson }} diff --git a/assets/vagrant/debian11-i386.Vagrantfile b/assets/vagrant/debian11-i386.Vagrantfile deleted file mode 100644 index 45fdf3c0c8b..00000000000 --- a/assets/vagrant/debian11-i386.Vagrantfile +++ /dev/null @@ -1,10 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "generic-x32/debian11" - config.vm.define "debian11-i386" - config.vm.hostname = "debian11-i386" - config.vm.synced_folder ".", "/chezmoi", type: "rsync" - config.vm.provision "shell", inline: <<-SHELL - DEBIAN_FRONTEND=noninteractive apt-get install -y age gpg golang unzip zip - SHELL - config.vm.provision "file", source: "assets/vagrant/debian11-i386.test-chezmoi.sh", destination: "test-chezmoi.sh" -end diff --git a/assets/vagrant/debian11-i386.test-chezmoi.sh b/assets/vagrant/debian11-i386.test-chezmoi.sh deleted file mode 100755 index 1a822697670..00000000000 --- a/assets/vagrant/debian11-i386.test-chezmoi.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -set -eufo pipefail - -GO_VERSION=$(grep GO_VERSION: /chezmoi/.github/workflows/main.yml | awk '{ print $2 }' ) - -go get "golang.org/dl/go${GO_VERSION}" -"${HOME}/go/bin/go${GO_VERSION}" download -export PATH="${HOME}/sdk/go${GO_VERSION}/bin:${PATH}" - -( cd /chezmoi && go test ./... ) diff --git a/assets/vagrant/freebsd13.Vagrantfile b/assets/vagrant/freebsd13.Vagrantfile deleted file mode 100644 index e9d5fd08431..00000000000 --- a/assets/vagrant/freebsd13.Vagrantfile +++ /dev/null @@ -1,10 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "generic/freebsd13" - config.vm.define :freebsd13 - config.vm.hostname = "freebsd13" - config.vm.synced_folder ".", "/chezmoi", type: "rsync" - config.vm.provision "shell", inline: <<-SHELL - pkg install --quiet --yes age git gnupg go zip - SHELL - config.vm.provision "file", source: "assets/vagrant/freebsd13.test-chezmoi.sh", destination: "test-chezmoi.sh" -end diff --git a/assets/vagrant/freebsd13.test-chezmoi.sh b/assets/vagrant/freebsd13.test-chezmoi.sh deleted file mode 100755 index a27de7948e0..00000000000 --- a/assets/vagrant/freebsd13.test-chezmoi.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -eufo pipefail - -( cd /chezmoi && go test ./... ) diff --git a/assets/vagrant/freebsd14.Vagrantfile b/assets/vagrant/freebsd14.Vagrantfile new file mode 100644 index 00000000000..69196ed20c8 --- /dev/null +++ b/assets/vagrant/freebsd14.Vagrantfile @@ -0,0 +1,17 @@ +Vagrant.configure("2") do |config| + config.vm.box = "generic/freebsd14" + config.vm.define :freebsd14 + config.vm.hostname = "freebsd14" + config.vm.synced_folder ".", "/chezmoi", type: "rsync" + config.vm.provision "shell", inline: <<-SHELL + pkg install --quiet --yes age git gnupg go zip + SHELL + config.vm.provision "shell", inline: <<-SHELL + echo CHEZMOI_GITHUB_ACCESS_TOKEN=#{ENV['CHEZMOI_GITHUB_ACCESS_TOKEN']} >> /home/vagrant/.bash_profile + echo CHEZMOI_GITHUB_TOKEN=#{ENV['CHEZMOI_GITHUB_TOKEN']} >> /home/vagrant/.bash_profile + echo GITHUB_ACCESS_TOKEN=#{ENV['GITHUB_ACCESS_TOKEN']} >> /home/vagrant/.bash_profile + echo GITHUB_TOKEN=#{ENV['GITHUB_TOKEN']} >> /home/vagrant/.bash_profile + echo export CHEZMOI_GITHUB_ACCESS_TOKEN CHEZMOI_GITHUB_TOKEN GITHUB_ACCESS_TOKEN GITHUB_TOKEN >> /home/vagrant/.bash_profile + SHELL + config.vm.provision "file", source: "assets/vagrant/freebsd14.test-chezmoi.sh", destination: "test-chezmoi.sh" +end diff --git a/assets/vagrant/freebsd14.test-chezmoi.sh b/assets/vagrant/freebsd14.test-chezmoi.sh new file mode 100755 index 00000000000..47566250a8e --- /dev/null +++ b/assets/vagrant/freebsd14.test-chezmoi.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -eufo pipefail + +git config --global --add safe.directory /chezmoi + +cd /chezmoi + +go test ./... + +sh assets/scripts/install.sh +bin/chezmoi --version diff --git a/assets/vagrant/openbsd6.Vagrantfile b/assets/vagrant/openbsd6.Vagrantfile deleted file mode 100644 index 46e777778cc..00000000000 --- a/assets/vagrant/openbsd6.Vagrantfile +++ /dev/null @@ -1,10 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "generic/openbsd6" - config.vm.define :openbsd6 - config.vm.hostname = "openbsd6" - config.vm.synced_folder ".", "/chezmoi", type: "rsync" - config.vm.provision "shell", inline: <<-SHELL - pkg_add -x bzip2 git gnupg go zip - SHELL - config.vm.provision "file", source: "assets/vagrant/openbsd6.test-chezmoi.sh", destination: "test-chezmoi.sh" -end diff --git a/assets/vagrant/openbsd6.test-chezmoi.sh b/assets/vagrant/openbsd6.test-chezmoi.sh deleted file mode 100755 index a27de7948e0..00000000000 --- a/assets/vagrant/openbsd6.test-chezmoi.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -eufo pipefail - -( cd /chezmoi && go test ./... ) diff --git a/assets/vagrant/openbsd7.Vagrantfile b/assets/vagrant/openbsd7.Vagrantfile new file mode 100644 index 00000000000..01d958cc44a --- /dev/null +++ b/assets/vagrant/openbsd7.Vagrantfile @@ -0,0 +1,15 @@ +Vagrant.configure("2") do |config| + config.vm.box = "generic/openbsd7" + config.vm.define :openbsd7 + config.vm.hostname = "openbsd7" + config.vm.synced_folder ".", "/chezmoi", type: "rsync" + config.vm.provision "shell", inline: <<-SHELL + pkg_add -x bzip2 git gnupg go zip + echo CHEZMOI_GITHUB_ACCESS_TOKEN=#{ENV['CHEZMOI_GITHUB_ACCESS_TOKEN']} >> /home/vagrant/.bash_profile + echo CHEZMOI_GITHUB_TOKEN=#{ENV['CHEZMOI_GITHUB_TOKEN']} >> /home/vagrant/.bash_profile + echo GITHUB_ACCESS_TOKEN=#{ENV['GITHUB_ACCESS_TOKEN']} >> /home/vagrant/.bash_profile + echo GITHUB_TOKEN=#{ENV['GITHUB_TOKEN']} >> /home/vagrant/.bash_profile + echo export CHEZMOI_GITHUB_ACCESS_TOKEN CHEZMOI_GITHUB_TOKEN GITHUB_ACCESS_TOKEN GITHUB_TOKEN >> /home/vagrant/.bash_profile + SHELL + config.vm.provision "file", source: "assets/vagrant/openbsd7.test-chezmoi.sh", destination: "test-chezmoi.sh" +end diff --git a/assets/vagrant/openbsd7.test-chezmoi.sh b/assets/vagrant/openbsd7.test-chezmoi.sh new file mode 100755 index 00000000000..47566250a8e --- /dev/null +++ b/assets/vagrant/openbsd7.test-chezmoi.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -eufo pipefail + +git config --global --add safe.directory /chezmoi + +cd /chezmoi + +go test ./... + +sh assets/scripts/install.sh +bin/chezmoi --version diff --git a/assets/vagrant/openindiana.Vagrantfile b/assets/vagrant/openindiana.Vagrantfile deleted file mode 100644 index c9b30cbe522..00000000000 --- a/assets/vagrant/openindiana.Vagrantfile +++ /dev/null @@ -1,9 +0,0 @@ -Vagrant.configure("2") do |config| - config.vm.box = "openindiana/hipster" - config.vm.box_version = "202109" - config.vm.synced_folder ".", "/chezmoi", type: "rsync" - config.vm.provision "shell", inline: <<-SHELL - pkg install -q compress/zip developer/gcc-7 developer/golang developer/versioning/git - SHELL - config.vm.provision "file", source: "assets/vagrant/openindiana.test-chezmoi.sh", destination: "test-chezmoi.sh" -end diff --git a/assets/vagrant/openindiana.test-chezmoi.sh b/assets/vagrant/openindiana.test-chezmoi.sh deleted file mode 100755 index a27de7948e0..00000000000 --- a/assets/vagrant/openindiana.test-chezmoi.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -eufo pipefail - -( cd /chezmoi && go test ./... ) diff --git a/assets/vagrant/test.sh b/assets/vagrant/test.sh index d5a995960ac..38707e64159 100755 --- a/assets/vagrant/test.sh +++ b/assets/vagrant/test.sh @@ -3,18 +3,19 @@ set -eufo pipefail for os in "$@"; do - if [ ! -f "${os}.Vagrantfile" ]; then - echo "${os}.Vagrantfile not found" - exit 1 - fi - export VAGRANT_VAGRANTFILE=assets/vagrant/${os}.Vagrantfile - if ! ( cd ../.. && vagrant up ); then - exit 1 - fi - vagrant ssh -c "./test-chezmoi.sh" - vagrant_ssh_exit_code=$? - vagrant destroy -f || exit 1 - if [ $vagrant_ssh_exit_code -ne 0 ]; then - exit $vagrant_ssh_exit_code - fi + echo "${os}" + if [ ! -f "${os}.Vagrantfile" ]; then + echo "${os}.Vagrantfile not found" + exit 1 + fi + export VAGRANT_VAGRANTFILE=assets/vagrant/${os}.Vagrantfile + if ! (cd ../.. && vagrant up); then + exit 1 + fi + vagrant ssh -c "./test-chezmoi.sh" + vagrant_ssh_exit_code=$? + vagrant destroy -f || exit 1 + if [ $vagrant_ssh_exit_code -ne 0 ]; then + exit $vagrant_ssh_exit_code + fi done diff --git a/completions/chezmoi-completion.bash b/completions/chezmoi-completion.bash index 8029e5579e4..65819e529aa 100644 --- a/completions/chezmoi-completion.bash +++ b/completions/chezmoi-completion.bash @@ -2,7 +2,7 @@ __chezmoi_debug() { - if [[ -n ${BASH_COMP_DEBUG_FILE:-} ]]; then + if [[ -n ${BASH_COMP_DEBUG_FILE-} ]]; then echo "$*" >> "${BASH_COMP_DEBUG_FILE}" fi } @@ -21,7 +21,7 @@ __chezmoi_get_completion_results() { local requestComp lastParam lastChar args # Prepare the command to request completions for the program. - # Calling ${words[0]} instead of directly chezmoi allows to handle aliases + # Calling ${words[0]} instead of directly chezmoi allows handling aliases args=("${words[@]:1}") requestComp="${words[0]} __complete ${args[*]}" @@ -29,7 +29,7 @@ __chezmoi_get_completion_results() { lastChar=${lastParam:$((${#lastParam}-1)):1} __chezmoi_debug "lastParam ${lastParam}, lastChar ${lastChar}" - if [ -z "${cur}" ] && [ "${lastChar}" != "=" ]; then + 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 "Adding extra empty parameter" @@ -39,7 +39,7 @@ __chezmoi_get_completion_results() { # When completing a flag with an = (e.g., chezmoi -n=) # bash focuses on the part after the =, so we need to remove # the flag part from $cur - if [[ "${cur}" == -*=* ]]; then + if [[ ${cur} == -*=* ]]; then cur="${cur#*=}" fi @@ -51,12 +51,12 @@ __chezmoi_get_completion_results() { directive=${out##*:} # Remove the directive out=${out%:*} - if [ "${directive}" = "${out}" ]; then + if [[ ${directive} == "${out}" ]]; then # There is not directive specified directive=0 fi __chezmoi_debug "The completion directive is: ${directive}" - __chezmoi_debug "The completions are: ${out[*]}" + __chezmoi_debug "The completions are: ${out}" } __chezmoi_process_completion_results() { @@ -65,22 +65,36 @@ __chezmoi_process_completion_results() { local shellCompDirectiveNoFileComp=4 local shellCompDirectiveFilterFileExt=8 local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 - if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + if (((directive & shellCompDirectiveError) != 0)); then # Error code. No completion. __chezmoi_debug "Received error from custom completion go code" return else - if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then - if [[ $(type -t compopt) = "builtin" ]]; then + if (((directive & shellCompDirectiveNoSpace) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then __chezmoi_debug "Activating no space" compopt -o nospace else __chezmoi_debug "No space directive not supported in this version of bash" fi fi - if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then - if [[ $(type -t compopt) = "builtin" ]]; then + if (((directive & shellCompDirectiveKeepOrder) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + # no sort isn't supported for bash less than < 4.4 + if [[ ${BASH_VERSINFO[0]} -lt 4 || ( ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 4 ) ]]; then + __chezmoi_debug "No sort directive not supported in this version of bash" + else + __chezmoi_debug "Activating keep order" + compopt -o nosort + fi + else + __chezmoi_debug "No sort directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveNoFileComp) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then __chezmoi_debug "Activating no file completion" compopt +o default else @@ -89,26 +103,30 @@ __chezmoi_process_completion_results() { fi fi - if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # Separate activeHelp from normal completions + local completions=() + local activeHelp=() + __chezmoi_extract_activeHelp + + if (((directive & shellCompDirectiveFilterFileExt) != 0)); then # File extension filtering local fullFilter filter filteringCmd - # Do not use quotes around the $out variable or else newline + # Do not use quotes around the $completions variable or else newline # characters will be kept. - for filter in ${out[*]}; do + for filter in ${completions[*]}; do fullFilter+="$filter|" done filteringCmd="_filedir $fullFilter" __chezmoi_debug "File filtering command: $filteringCmd" $filteringCmd - elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + elif (((directive & shellCompDirectiveFilterDirs) != 0)); then # File completion for directories only - # Use printf to strip any trailing newline local subdir - subdir=$(printf "%s" "${out[0]}") - if [ -n "$subdir" ]; then + subdir=${completions[0]} + if [[ -n $subdir ]]; then __chezmoi_debug "Listing directories in $subdir" pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return else @@ -116,52 +134,110 @@ __chezmoi_process_completion_results() { _filedir -d fi else - __chezmoi_handle_standard_completion_case + __chezmoi_handle_completion_types fi __chezmoi_handle_special_char "$cur" : __chezmoi_handle_special_char "$cur" = + + # Print the activeHelp statements before we finish + if ((${#activeHelp[*]} != 0)); then + printf "\n"; + printf "%s\n" "${activeHelp[@]}" + printf "\n" + + # The prompt format is only available from bash 4.4. + # We test if it is available before using it. + if (x=${PS1@P}) 2> /dev/null; then + printf "%s" "${PS1@P}${COMP_LINE[@]}" + else + # Can't print the prompt. Just print the + # text the user had typed, it is workable enough. + printf "%s" "${COMP_LINE[@]}" + fi + fi +} + +# Separate activeHelp lines from real completions. +# Fills the $activeHelp and $completions arrays. +__chezmoi_extract_activeHelp() { + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + + while IFS='' read -r comp; do + if [[ ${comp:0:endIndex} == $activeHelpMarker ]]; then + comp=${comp:endIndex} + __chezmoi_debug "ActiveHelp found: $comp" + if [[ -n $comp ]]; then + activeHelp+=("$comp") + fi + else + # Not an activeHelp line but a normal completion + completions+=("$comp") + fi + done <<<"${out}" +} + +__chezmoi_handle_completion_types() { + __chezmoi_debug "__chezmoi_handle_completion_types: COMP_TYPE is $COMP_TYPE" + + case $COMP_TYPE in + 37|42) + # Type: menu-complete/menu-complete-backward and insert-completions + # If the user requested inserting one completion at a time, or all + # completions at once on the command-line we must remove the descriptions. + # https://github.com/spf13/cobra/issues/1508 + local tab=$'\t' comp + while IFS='' read -r comp; do + [[ -z $comp ]] && continue + # Strip any description + comp=${comp%%$tab*} + # Only consider the completions that match + if [[ $comp == "$cur"* ]]; then + COMPREPLY+=("$comp") + fi + done < <(printf "%s\n" "${completions[@]}") + ;; + + *) + # Type: complete (normal completion) + __chezmoi_handle_standard_completion_case + ;; + esac } __chezmoi_handle_standard_completion_case() { - local tab comp - tab=$(printf '\t') + local tab=$'\t' comp + + # Short circuit to optimize if we don't have descriptions + if [[ "${completions[*]}" != *$tab* ]]; then + IFS=$'\n' read -ra COMPREPLY -d '' < <(compgen -W "${completions[*]}" -- "$cur") + return 0 + fi local longest=0 + local compline # Look for the longest completion so that we can format things nicely - while IFS='' read -r comp; do + while IFS='' read -r compline; do + [[ -z $compline ]] && continue # Strip any description before checking the length - comp=${comp%%$tab*} + comp=${compline%%$tab*} # Only consider the completions that match - comp=$(compgen -W "$comp" -- "$cur") + [[ $comp == "$cur"* ]] || continue + COMPREPLY+=("$compline") if ((${#comp}>longest)); then longest=${#comp} fi - done < <(printf "%s\n" "${out[@]}") - - local completions=() - while IFS='' read -r comp; do - if [ -z "$comp" ]; then - continue - fi - - __chezmoi_debug "Original comp: $comp" - comp="$(__chezmoi_format_comp_descriptions "$comp" "$longest")" - __chezmoi_debug "Final comp: $comp" - completions+=("$comp") - done < <(printf "%s\n" "${out[@]}") - - while IFS='' read -r comp; do - COMPREPLY+=("$comp") - done < <(compgen -W "${completions[*]}" -- "$cur") + done < <(printf "%s\n" "${completions[@]}") # If there is a single completion left, remove the description text - if [ ${#COMPREPLY[*]} -eq 1 ]; then + if ((${#COMPREPLY[*]} == 1)); then __chezmoi_debug "COMPREPLY[0]: ${COMPREPLY[0]}" - comp="${COMPREPLY[0]%% *}" + comp="${COMPREPLY[0]%%$tab*}" __chezmoi_debug "Removed description from single completion, which is now: ${comp}" - COMPREPLY=() - COMPREPLY+=("$comp") + COMPREPLY[0]=$comp + else # Format the descriptions + __chezmoi_format_comp_descriptions $longest fi } @@ -172,53 +248,56 @@ __chezmoi_handle_special_char() if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then local word=${comp%"${comp##*${char}}"} local idx=${#COMPREPLY[*]} - while [[ $((--idx)) -ge 0 ]]; do - COMPREPLY[$idx]=${COMPREPLY[$idx]#"$word"} + while ((--idx >= 0)); do + COMPREPLY[idx]=${COMPREPLY[idx]#"$word"} done fi } __chezmoi_format_comp_descriptions() { - local tab - tab=$(printf '\t') - local comp="$1" - local longest=$2 - - # Properly format the description string which follows a tab character if there is one - if [[ "$comp" == *$tab* ]]; then - desc=${comp#*$tab} - comp=${comp%%$tab*} - - # $COLUMNS stores the current shell width. - # Remove an extra 4 because we add 2 spaces and 2 parentheses. - maxdesclength=$(( COLUMNS - longest - 4 )) - - # Make sure we can fit a description of at least 8 characters - # if we are to align the descriptions. - if [[ $maxdesclength -gt 8 ]]; then - # Add the proper number of spaces to align the descriptions - for ((i = ${#comp} ; i < longest ; i++)); do - comp+=" " - done - else - # Don't pad the descriptions so we can fit more text after the completion - maxdesclength=$(( COLUMNS - ${#comp} - 4 )) - fi + local tab=$'\t' + local comp desc maxdesclength + local longest=$1 + + local i ci + for ci in ${!COMPREPLY[*]}; do + comp=${COMPREPLY[ci]} + # Properly format the description string which follows a tab character if there is one + if [[ "$comp" == *$tab* ]]; then + __chezmoi_debug "Original comp: $comp" + desc=${comp#*$tab} + comp=${comp%%$tab*} + + # $COLUMNS stores the current shell width. + # Remove an extra 4 because we add 2 spaces and 2 parentheses. + maxdesclength=$(( COLUMNS - longest - 4 )) + + # Make sure we can fit a description of at least 8 characters + # if we are to align the descriptions. + if ((maxdesclength > 8)); then + # Add the proper number of spaces to align the descriptions + for ((i = ${#comp} ; i < longest ; i++)); do + comp+=" " + done + else + # Don't pad the descriptions so we can fit more text after the completion + maxdesclength=$(( COLUMNS - ${#comp} - 4 )) + fi - # If there is enough space for any description text, - # truncate the descriptions that are too long for the shell width - if [ $maxdesclength -gt 0 ]; then - if [ ${#desc} -gt $maxdesclength ]; then - desc=${desc:0:$(( maxdesclength - 1 ))} - desc+="…" + # If there is enough space for any description text, + # truncate the descriptions that are too long for the shell width + if ((maxdesclength > 0)); then + if ((${#desc} > maxdesclength)); then + desc=${desc:0:$(( maxdesclength - 1 ))} + desc+="…" + fi + comp+=" ($desc)" fi - comp+=" ($desc)" + COMPREPLY[ci]=$comp + __chezmoi_debug "Final comp: $comp" fi - fi - - # Must use printf to escape all special characters - printf "%q" "${comp}" + done } __start_chezmoi() @@ -230,9 +309,9 @@ __start_chezmoi() # Call _init_completion from the bash-completion package # to prepare the arguments properly if declare -F _init_completion >/dev/null 2>&1; then - _init_completion -n "=:" || return + _init_completion -n =: || return else - __chezmoi_init_completion -n "=:" || return + __chezmoi_init_completion -n =: || return fi __chezmoi_debug diff --git a/completions/chezmoi.fish b/completions/chezmoi.fish index e51850d368c..2e2a6464c47 100644 --- a/completions/chezmoi.fish +++ b/completions/chezmoi.fish @@ -18,7 +18,8 @@ function __chezmoi_perform_completion __chezmoi_debug "args: $args" __chezmoi_debug "last arg: $lastArg" - set -l requestComp "$args[1] __complete $args[2..-1] $lastArg" + # Disable ActiveHelp which is not supported for fish shell + set -l requestComp "CHEZMOI_ACTIVE_HELP=0 $args[1] __complete $args[2..-1] $lastArg" __chezmoi_debug "Calling $requestComp" set -l results (eval $requestComp 2> /dev/null) @@ -54,6 +55,60 @@ function __chezmoi_perform_completion printf "%s\n" "$directiveLine" end +# this function limits calls to __chezmoi_perform_completion, by caching the result behind $__chezmoi_perform_completion_once_result +function __chezmoi_perform_completion_once + __chezmoi_debug "Starting __chezmoi_perform_completion_once" + + if test -n "$__chezmoi_perform_completion_once_result" + __chezmoi_debug "Seems like a valid result already exists, skipping __chezmoi_perform_completion" + return 0 + end + + set --global __chezmoi_perform_completion_once_result (__chezmoi_perform_completion) + if test -z "$__chezmoi_perform_completion_once_result" + __chezmoi_debug "No completions, probably due to a failure" + return 1 + end + + __chezmoi_debug "Performed completions and set __chezmoi_perform_completion_once_result" + return 0 +end + +# this function is used to clear the $__chezmoi_perform_completion_once_result variable after completions are run +function __chezmoi_clear_perform_completion_once_result + __chezmoi_debug "" + __chezmoi_debug "========= clearing previously set __chezmoi_perform_completion_once_result variable ==========" + set --erase __chezmoi_perform_completion_once_result + __chezmoi_debug "Successfully erased the variable __chezmoi_perform_completion_once_result" +end + +function __chezmoi_requires_order_preservation + __chezmoi_debug "" + __chezmoi_debug "========= checking if order preservation is required ==========" + + __chezmoi_perform_completion_once + if test -z "$__chezmoi_perform_completion_once_result" + __chezmoi_debug "Error determining if order preservation is required" + return 1 + end + + set -l directive (string sub --start 2 $__chezmoi_perform_completion_once_result[-1]) + __chezmoi_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder 32 + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __chezmoi_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __chezmoi_debug "This does require order preservation" + return 0 + end + + __chezmoi_debug "This doesn't require order preservation" + return 1 +end + + # This function does two things: # - Obtain the completions and store them in the global __chezmoi_comp_results # - Return false if file completion should be performed @@ -64,17 +119,17 @@ function __chezmoi_prepare_completions # Start fresh set --erase __chezmoi_comp_results - set -l results (__chezmoi_perform_completion) - __chezmoi_debug "Completion results: $results" + __chezmoi_perform_completion_once + __chezmoi_debug "Completion results: $__chezmoi_perform_completion_once_result" - if test -z "$results" + if test -z "$__chezmoi_perform_completion_once_result" __chezmoi_debug "No completion, probably due to a failure" # Might as well do file completion, in case it helps return 1 end - set -l directive (string sub --start 2 $results[-1]) - set --global __chezmoi_comp_results $results[1..-2] + set -l directive (string sub --start 2 $__chezmoi_perform_completion_once_result[-1]) + set --global __chezmoi_comp_results $__chezmoi_perform_completion_once_result[1..-2] __chezmoi_debug "Completions are: $__chezmoi_comp_results" __chezmoi_debug "Directive is: $directive" @@ -170,7 +225,11 @@ end # Remove any pre-existing completions for the program since we will be handling all of them. complete -c chezmoi -e +# this will get called after the two calls below and clear the $__chezmoi_perform_completion_once_result global +complete -c chezmoi -n '__chezmoi_clear_perform_completion_once_result' # The call to __chezmoi_prepare_completions will setup __chezmoi_comp_results # which provides the program's completion choices. -complete -c chezmoi -n '__chezmoi_prepare_completions' -f -a '$__chezmoi_comp_results' - +# If this doesn't require order preservation, we don't use the -k flag +complete -c chezmoi -n 'not __chezmoi_requires_order_preservation && __chezmoi_prepare_completions' -f -a '$__chezmoi_comp_results' +# otherwise we use the -k flag +complete -k -c chezmoi -n '__chezmoi_requires_order_preservation && __chezmoi_prepare_completions' -f -a '$__chezmoi_comp_results' diff --git a/completions/chezmoi.ps1 b/completions/chezmoi.ps1 index 7cadaee74c0..2912f5238e6 100644 --- a/completions/chezmoi.ps1 +++ b/completions/chezmoi.ps1 @@ -10,7 +10,7 @@ filter __chezmoi_escapeStringWithSpecialChars { $_ -replace '\s|#|@|\$|;|,|''|\{|\}|\(|\)|"|`|\||<|>|&','`$&' } -Register-ArgumentCompleter -CommandName 'chezmoi' -ScriptBlock { +[scriptblock]${__chezmoiCompleterBlock} = { param( $WordToComplete, $CommandAst, @@ -33,17 +33,19 @@ Register-ArgumentCompleter -CommandName 'chezmoi' -ScriptBlock { if ($Command.Length -gt $CursorPosition) { $Command=$Command.Substring(0,$CursorPosition) } - __chezmoi_debug "Truncated command: $Command" + __chezmoi_debug "Truncated command: $Command" $ShellCompDirectiveError=1 $ShellCompDirectiveNoSpace=2 $ShellCompDirectiveNoFileComp=4 $ShellCompDirectiveFilterFileExt=8 $ShellCompDirectiveFilterDirs=16 + $ShellCompDirectiveKeepOrder=32 - # Prepare the command to request completions for the program. + # Prepare the command to request completions for the program. # Split the command at the first space to separate the program and arguments. $Program,$Arguments = $Command.Split(" ",2) + $RequestComp="$Program __completeNoDesc $Arguments" __chezmoi_debug "RequestComp: $RequestComp" @@ -68,16 +70,27 @@ Register-ArgumentCompleter -CommandName 'chezmoi' -ScriptBlock { # 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 "Adding extra empty parameter" - # We need to use `"`" to pass an empty argument a "" or '' does not work!!! - $RequestComp="$RequestComp" + ' `"`"' + # PowerShell 7.2+ changed the way how the arguments are passed to executables, + # so for pre-7.2 or when Legacy argument passing is enabled we need to use + # `"`" to pass an empty argument, a "" or '' does not work!!! + if ($PSVersionTable.PsVersion -lt [version]'7.2.0' -or + ($PSVersionTable.PsVersion -lt [version]'7.3.0' -and -not [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -or + (($PSVersionTable.PsVersion -ge [version]'7.3.0' -or [ExperimentalFeature]::IsEnabled("PSNativeCommandArgumentPassing")) -and + $PSNativeCommandArgumentPassing -eq 'Legacy')) { + $RequestComp="$RequestComp" + ' `"`"' + } else { + $RequestComp="$RequestComp" + ' ""' + } } __chezmoi_debug "Calling $RequestComp" + # First disable ActiveHelp which is not supported for Powershell + ${env:CHEZMOI_ACTIVE_HELP}=0 + #call the command store the output in $out and redirect stderr and stdout to null # $Out is an array contains each line per element Invoke-Expression -OutVariable out "$RequestComp" 2>&1 | Out-Null - # get directive from last line [int]$Directive = $Out[-1].TrimStart(':') if ($Directive -eq "") { @@ -97,7 +110,7 @@ Register-ArgumentCompleter -CommandName 'chezmoi' -ScriptBlock { } $Longest = 0 - $Values = $Out | ForEach-Object { + [Array]$Values = $Out | ForEach-Object { #Split the output in name and description $Name, $Description = $_.Split("`t",2) __chezmoi_debug "Name: $Name Description: $Description" @@ -142,6 +155,11 @@ Register-ArgumentCompleter -CommandName 'chezmoi' -ScriptBlock { } } + # we sort the values in ascending order by name if keep order isn't passed + if (($Directive -band $ShellCompDirectiveKeepOrder) -eq 0 ) { + $Values = $Values | Sort-Object -Property Name + } + if (($Directive -band $ShellCompDirectiveNoFileComp) -ne 0 ) { __chezmoi_debug "ShellCompDirectiveNoFileComp is called" @@ -216,10 +234,12 @@ Register-ArgumentCompleter -CommandName 'chezmoi' -ScriptBlock { Default { # Like MenuComplete but we don't want to add a space here because # the user need to press space anyway to get the completion. - # Description will not be shown because thats not possible with TabCompleteNext + # Description will not be shown because that's not possible with TabCompleteNext [System.Management.Automation.CompletionResult]::new($($comp.Name | __chezmoi_escapeStringWithSpecialChars), "$($comp.Name)", 'ParameterValue', "$($comp.Description)") } } } } + +Register-ArgumentCompleter -CommandName 'chezmoi' -ScriptBlock ${__chezmoiCompleterBlock} diff --git a/completions/chezmoi.zsh b/completions/chezmoi.zsh index 573b0c4f547..46af48dcd03 100644 --- a/completions/chezmoi.zsh +++ b/completions/chezmoi.zsh @@ -1,4 +1,5 @@ -#compdef _chezmoi chezmoi +#compdef chezmoi +compdef _chezmoi chezmoi # zsh completion for chezmoi -*- shell-script -*- @@ -17,8 +18,9 @@ _chezmoi() local shellCompDirectiveNoFileComp=4 local shellCompDirectiveFilterFileExt=8 local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 - local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder local -a completions __chezmoi_debug "\n========= starting completion logic ==========" @@ -86,7 +88,24 @@ _chezmoi() return fi + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __chezmoi_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __chezmoi_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + if [ -n "$comp" ]; then # If requested, completions are returned with a description. # The description is preceded by a TAB character. @@ -94,7 +113,7 @@ _chezmoi() # We first need to escape any : as part of the completion itself. comp=${comp//:/\\:} - local tab=$(printf '\t') + local tab="$(printf '\t')" comp=${comp//$tab/:} __chezmoi_debug "Adding completion: ${comp}" @@ -103,11 +122,27 @@ _chezmoi() fi done < <(printf "%s\n" "${out[@]}") + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __chezmoi_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then __chezmoi_debug "Activating nospace." noSpace="-S ''" fi + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __chezmoi_debug "Activating keep order." + keepOrder="-V" + fi + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then # File extension filtering local filteringCmd @@ -125,7 +160,7 @@ _chezmoi() _arguments '*:filename:'"$filteringCmd" elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then # File completion for directories only - local subDir + local subdir subdir="${completions[1]}" if [ -n "$subdir" ]; then __chezmoi_debug "Listing directories in $subdir" @@ -143,7 +178,7 @@ _chezmoi() return $result else __chezmoi_debug "Calling _describe" - if eval _describe "completions" completions $flagPrefix $noSpace; then + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then __chezmoi_debug "_describe found some completions" # Return the success of having called _describe @@ -173,5 +208,5 @@ _chezmoi() # don't run the completion function when being source-ed or eval-ed if [ "$funcstack[1]" = "_chezmoi" ]; then - _chezmoi + _chezmoi fi diff --git a/docs/CHANGES.md b/docs/CHANGES.md deleted file mode 100644 index 18bf5d2f52b..00000000000 --- a/docs/CHANGES.md +++ /dev/null @@ -1,134 +0,0 @@ -# chezmoi changes - - -* [Overview](#overview) -* [Version 2](#version-2) - * [New features in version 2](#new-features-in-version-2) - * [Changes from version 1](#changes-from-version-1) - ---- - -## Overview - -This document describes what's new in chezmoi v2 for chezmoi v1 users. - -If you're not using a dotfile manager, then read [why you should you use a -dotfile -manager](https://github.com/twpayne/chezmoi/blob/master/docs/COMPARISON.md#why-should-i-use-a-dotfile-manager) -and then [install -chezmoi](https://github.com/twpayne/chezmoi/blob/master/docs/INSTALL.md) and -follow the [quick-start -guide](https://github.com/twpayne/chezmoi/blob/master/docs/QUICKSTART.md). - -If you're already using a dotfile manager, but not chezmoi, then read about [how -chezmoi compares to other dotfile -managers](https://github.com/twpayne/chezmoi/blob/master/docs/COMPARISON.md) -first and then [install -chezmoi](https://github.com/twpayne/chezmoi/blob/master/docs/INSTALL.md), follow -the [quick-start -guide](https://github.com/twpayne/chezmoi/blob/master/docs/QUICKSTART.md), and -read the [how-to -guide](https://github.com/twpayne/chezmoi/blob/master/docs/HOWTO.md) to quickly -discover chezmoi's functionality. - ---- - -## Version 2 - -chezmoi version 2 brings many new features and fixes a few corner-case bugs. -Very few, if any, changes should be required to your source directory, -templates, or config file. - ---- - -### New features in version 2 - -* The new `chezmoi status` command shows you a concise list of differences, much - like `git status`. -* The `chezmoi apply` command prompts you if a file has been modified by - something other than chezmoi since chezmoi last wrote it, keeping you in full - control of your changes, wherever you make them. -* The `chezmoi init` command will try to guess your dotfile repository if you - give it a short argument. For example, `chezmoi init username` is the - equivalent of `chezmoi init https://github.com/username/dotfiles.git`. -* chezmoi includes a builtin `git` command which it will use if it cannot find - `git` in your `$PATH`. This means that you don't even have to install `git` to - setup your dotfiles on a new machine. -* chezmoi detects when your config file template has changed and prompts you to - re-run `chezmoi init` if needed. -* The new `create_` attribute allows you to create a file with initial content, - but not have it overwritten by `chezmoi apply`. -* The new `modify_` attribute allows you to modify an existing file with a - script, so you can use chezmoi to manage parts, but not all, of a file. -* The new script attributes `before_` and `after_` control when scripts are run - relative to when your files are updated. -* The new `--exclude` option allows you to control what types of target will be - updated. For example `chezmoi apply --exclude=scripts` will cause chezmoi to - apply everything except scripts and `chezmoi init --apply --exclude=encrypted` - will exclude encrypted files. -* The new `--keep-going` option causes chezmoi to keep going as far as possible - rather than stopping at the first error it encounters. -* The new `--no-tty` option stops chezmoi from opening a TTY to read input - (including passwords) and instead reads them from stdin. -* The new `--source-path` option allows you to specify targets by source path, - which you can use in an on-save editor hook to automatically run `chezmoi - apply` when you edit a dotfile in your source state. -* The new `gitHubKeys` template function allows you to populate your - `~/.ssh/authorized_keys` from your public SSH keys on GitHub. -* The `promptBool` function now also recognizes `y`, `yes`, `on`, `n`, `no`, and - `off` as boolean values. -* The `chezmoi archive` command now includes scripts in the generated archive, - and can generate `.zip` files. -* The new `edit.command` and `edit.args` configuration variables give you more - control over the command invoked by `chezmoi edit`. -* The `chezmoi init` command has a new `--one-shot` option which does a shallow - clone of your dotfiles repo, runs `chezmoi apply`, and then removes your - source and configuration directories. It's the fastest way to set up your - dotfiles on a ephemeral machine and then remove all traces of chezmoi. -* Standard template variables are set on a best-effort basis. If errors are - encountered, chezmoi leaves the variable unset rather than terminating with - the error. -* The new `.chezmoi.version` template variable contains the version of chezmoi. - You can compare versions using [version comparison - functions](https://masterminds.github.io/sprig/semver.html). -* The new `.chezmoi.fqdnHostname` template variables contains the - fully-qualified domain name of the machine, if it can be determined. -* You can now encrypt whole files with [age](https://age-encryption.org). - ---- - -### Changes from version 1 - -chezmoi version 2 includes a few minor changes from version 1, mainly to enable -the new functionality and for consistency: - -* chezmoi uses a different format to persist its state. Specifically, this means - that all your `run_once_` scripts will be run again the first time you run - `chezmoi apply`. -* `chezmoi add`, and many other commands, are now recursive by default. -* `chezmoi apply` will warn if a file in the destination directory has been - modified since chezmoi last wrote it. To force overwriting, pass the `--force` - option. -* `chezmoi edit` no longer supports the `--prompt` option. -* The only diff format is now `git`. The `diff.format` configuration variable is - ignored. -* Diffs include the contents of scripts that would be run. -* Mercurial support has been removed. -* The `chezmoi source` command has been removed, use `chezmoi git` instead. -* The `sourceVCS` configuration group has been renamed to `git`. -* The order of files for a three-way merge passed to `merge.command` is now - actual file, target state, source state. -* The `chezmoi keyring` command has been moved to `chezmoi secret keyring`. -* The `genericSecret` configuration group has been renamed to `secret`. -* The `chezmoi chattr` command uses `encrypted` instead of `encrypt` as the - attribute for encrypted files. -* The gpg recipient is configured with the `gpg.recipient` configuration - variable, `gpgRecipient` is no longer used. -* The structure of data output by `chezmoi dump` has changed. -* The `.chezmoi.homedir` template variable has been replaced with - `.chezmoi.homeDir`. For compatibility, `.chezmoi.homedir` will continue to be - supported until version 2.1. -* The `.chezmoi.fullHostname` template variable has been removed, as it did not - contain the full hostname. Use `.chezmoi.fqdnHostname` (UNIX only) instead. - ---- diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md deleted file mode 100644 index b289535e97a..00000000000 --- a/docs/COMPARISON.md +++ /dev/null @@ -1,181 +0,0 @@ -# chezmoi comparison guide - - -* [Comparison table](#comparison-table) -* [Why should I use a dotfile manager?](#why-should-i-use-a-dotfile-manager) -* [I already have a system to manage my dotfiles, why should I use chezmoi?](#i-already-have-a-system-to-manage-my-dotfiles-why-should-i-use-chezmoi) - * [Coping with differences between machines requires extra effort](#coping-with-differences-between-machines-requires-extra-effort) - * [You have to keep your dotfiles repo private](#you-have-to-keep-your-dotfiles-repo-private) - * [You have to maintain your own tool](#you-have-to-maintain-your-own-tool) - * [Setting up your dotfiles requires more than one short command](#setting-up-your-dotfiles-requires-more-than-one-short-command) - ---- - -## Comparison table - -[chezmoi]: https://chezmoi.io/ -[dotbot]: https://github.com/anishathalye/dotbot -[rcm]: https://github.com/thoughtbot/rcm -[homesick]: https://github.com/technicalpickles/homesick -[vcsh]: https://github.com/RichiH/vcsh -[yadm]: https://yadm.io/ -[bare git]: https://www.atlassian.com/git/tutorials/dotfiles "bare git" - -| | [chezmoi] | [dotbot] | [rcm] | [homesick] | [vcsh] | [yadm] | [bare git] | -| -------------------------------------- | ------------- | ----------------- | ----------------- | ----------------- | ------------------------ | ------------- | ---------- | -| Distribution | Single binary | Python package | Multiple files | Ruby gem | Single script or package | Single script | - | -| Install method | Many | git submodule | Many | Ruby gem | Many | Many | Manual | -| Non-root install on bare system | 🟢 | 🟠 | 🟠 | 🟠 | 🟢 | 🟢 | 🟢 | -| Windows support | 🟢 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🟢 | -| Bootstrap requirements | None | Python, git | Perl, git | Ruby, git | sh, git | git | git | -| Source repos | Single | Single | Multiple | Single | Multiple | Single | Single | -| dotfiles are... | Files | Symlinks | Files | Symlinks | Files | Files | Files | -| Config file | Optional | Required | Optional | None | None | None | Optional | -| Private files | 🟢 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | -| Show differences without applying | 🟢 | 🔴 | 🔴 | 🔴 | 🟢 | 🟢 | 🟢 | -| Whole file encryption | 🟢 | 🔴 | 🔴 | 🔴 | 🔴 | 🟢 | 🔴 | -| Password manager integration | 🟢 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | -| Machine-to-machine file differences | Templates | Alternative files | Alternative files | Alternative files | Branches | Templates | 🟠 | -| Custom variables in templates | 🟢 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | -| Executable files | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | 🔴 | 🟢 | -| File creation with initial contents | 🟢 | 🔴 | 🔴 | 🔴 | 🟢 | 🔴 | 🔴 | -| Externals | 🟢 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 | -| Manage partial files | 🟢 | 🔴 | 🔴 | 🔴 | 🟠 | 🔴 | 🟠 | -| File removal | 🟢 | 🔴 | 🔴 | 🔴 | 🟢 | 🔴 | 🔴 | -| Directory creation | 🟢 | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 | 🟢 | -| Run scripts | 🟢 | 🟢 | 🟢 | 🔴 | 🟢 | 🔴 | 🔴 | -| Run once scripts | 🟢 | 🔴 | 🔴 | 🔴 | 🟢 | 🔴 | 🔴 | -| Machine-to-machine symlink differences | 🟢 | 🔴 | 🔴 | 🔴 | 🟠 | 🟢 | 🟠 | -| Shell completion | 🟢 | 🔴 | 🔴 | 🔴 | 🟢 | 🟢 | 🟢 | -| Archive import | 🟢 | 🔴 | 🔴 | 🔴 | 🟢 | 🔴 | 🟢 | -| Archive export | 🟢 | 🔴 | 🔴 | 🔴 | 🟢 | 🔴 | 🟢 | -| Implementation language | Go | Python | Perl | Ruby | POSIX Shell | Bash | C | - -🟢 Supported 🟠 Possible with significant manual effort 🔴 Not supported - -For more comparisons, visit [dotfiles.github.io](https://dotfiles.github.io/). - ---- - -## Why should I use a dotfile manager? - -Dotfile managers give you the combined benefit of a consistent environment -everywhere with an undo command and a restore from backup. - -As the core of our development environments become increasingly standardized -(e.g. git or Mercurial interfaces to version control at both home and work), and -we further customize them (with shell configs like -[powerlevel10k](https://github.com/romkatv/powerlevel10k)), at the same time we -increasingly work in ephemeral environments like Docker containers and [GitHub -Codespaces](https://github.com/features/codespaces). - -chezmoi helps you bring your personal configuration to every environment that -you're working in. In the same way that nobody would use an editor without an -undo command, or develop software without a version control system, chezmoi -brings the investment that you have made in mastering your tools to every -environment that you work in. - ---- - -## I already have a system to manage my dotfiles, why should I use chezmoi? - -> Regular reminder that chezmoi is the best dotfile manager utility I've used -> and you can too -> -> — [@mbbroberg](https://twitter.com/mbbroberg/status/1355644967625125892) - -If you're using any of the following methods: - -* A custom shell script. -* An existing dotfile manager like - [dotbot](https://github.com/anishathalye/dotbot), - [rcm](https://github.com/thoughtbot/rcm), - [homesick](https://github.com/technicalpickles/homesick), - [vcsh](https://github.com/RichiH/vcsh), - [yadm](https://yadm.io/), or [GNU Stow](https://www.gnu.org/software/stow/). -* A [bare git repo](https://www.atlassian.com/git/tutorials/dotfiles). - -Then you've probably run into at least one of the following problems. - ---- - -### Coping with differences between machines requires extra effort - -If you want to synchronize your dotfiles across multiple operating systems or -distributions, then you may need to manually perform extra steps to cope with -differences from machine to machine. You might need to run different commands on -different machines, maintain separate per-machine files or branches (with the -associated hassle of merging, rebasing, or copying each change), or hope that -your custom logic handles the differences correctly. - -chezmoi uses a single source of truth (a single branch) and a single command -that works on every machine. Individual files can be templates to handle machine -to machine differences, if needed. - ---- - -### You have to keep your dotfiles repo private - -> And regarding dotfiles, I saw that. It's only public dotfiles repos so I have -> to evaluate my dotfiles history to be sure. I have secrets scanning and more, -> but it was easier to keep it private for security, I'm ok mostly though. I'm -> using chezmoi and it's easier now -> -> — [@sheldon_hull](https://twitter.com/sheldon_hull/status/1308139570597371907) - -If your system stores secrets in plain text, then you must be very careful about -where you clone your dotfiles. If you clone them on your work machine then -anyone with access to your work machine (e.g. your IT department) will have -access to your home secrets. If you clone it on your home machine then you risk -leaking work secrets. - -With chezmoi you can store secrets in your password manager or encrypt them, and -even store passwords in different ways on different machines. You can clone your -dotfiles repository anywhere, and even make your dotfiles repo public, without -leaving personal secrets on your work machine or work secrets on your personal -machine. - ---- - -### You have to maintain your own tool - -> I've offloaded my dotfiles deployment from a homespun shell script to chezmoi. -> I'm quite happy with this decision. -> -> — [@gotgenes](https://twitter.com/gotgenes/status/1251008845163319297) - -> I discovered chezmoi and it's pretty cool, just migrated my old custom -> multi-machine sync dotfile setup and it's so much simpler now -> -> in case you're wondering I have written 0 code -> -> — [@buritica](https://twitter.com/buritica/status/1361062902451630089) - -If your system was written by you for your personal use, then it probably has -the functionality that you needed when you wrote it. If you need more -functionality then you have to implement it yourself. - -chezmoi includes a huge range of battle-tested functionality out-of-the-box, -including dry-run and diff modes, script execution, conflict resolution, Windows -support, and much, much more. chezmoi is [used by thousands of -people](https://github.com/twpayne/chezmoi/stargazers) and has a rich suite of -both unit and integration tests. When you hit the limits of your existing -dotfile management system, chezmoi already has a tried-and-tested solution ready -for you to use. - ---- - -### Setting up your dotfiles requires more than one short command - -If your system is written in a scripting language like Python, Perl, or Ruby, -then you also need to install a compatible version of that language's runtime -before you can use your system. - -chezmoi is distributed as a single stand-alone statically-linked binary with no -dependencies that you can simply copy onto your machine and run. You don't even -need git installed. chezmoi provides one-line installs, pre-built binaries, -packages for Linux and BSD distributions, Homebrew formulae, Scoop and -Chocolatey support on Windows, and a initial config file generation mechanism to -make installing your dotfiles on a new machine as painless as possible. - ---- diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md deleted file mode 100644 index ca8c02214fe..00000000000 --- a/docs/CONTRIBUTING.md +++ /dev/null @@ -1,290 +0,0 @@ -# chezmoi contributing guide - - -* [Getting started](#getting-started) -* [Developing locally](#developing-locally) -* [Generated code](#generated-code) -* [Contributing changes](#contributing-changes) -* [Managing releases](#managing-releases) -* [Building and installing with `make`](#building-and-installing-with-make) -* [Packaging](#packaging) -* [Updating the website](#updating-the-website) - ---- - -## Getting started - -chezmoi is written in [Go](https://golang.org) and development happens on -[GitHub](https://github.com). The rest of this document assumes that you've -checked out chezmoi locally. - -The [Architecture Guide](ARCHITECTURE.md) contains a high-level overview of -chezmoi's source code. - ---- - -## Developing locally - -chezmoi requires Go 1.16 or later. - -chezmoi is a standard Go project, using standard Go tooling. - -Build chezmoi: - -```console -$ go build -``` - -Run all tests: - -```console -$ go test ./... -``` - -chezmoi's tests include integration tests with other software. If the other -software is not found in `$PATH` the tests will be skipped. Running the full set -of tests requires `age`, `base64`, `bash`, `gpg`, `perl`, `python`, `ruby`, -`sed`, `sha256sum`, `unzip`, and `zip`. - -Run chezmoi: - -```console -$ go run . -``` - -Run a set of smoketests, including cross-compilation, tests, and linting: - -```console -$ make smoketest -``` - ---- - -## Generated code - -chezmoi generates the install script and the website from a single source of -truth. You must run - -```console -$ go generate -``` - -if you change includes any of the following: - -* Adds or modifies the list of supported OSs and architectures. -* Modifies the install script template. - -chezmoi's continuous integration verifies that all generated files are up to -date. Changes to generated files should be included in the commit that modifies -the source of truth. - ---- - -## Contributing changes - -Bug reports, bug fixes, and documentation improvements are always welcome. -Please [open an issue](https://github.com/twpayne/chezmoi/issues/new/choose) or -[create a pull -request](https://help.github.com/en/articles/creating-a-pull-request) with your -report, fix, or improvement. - -If you want to make a more significant change, please first [open an -issue](https://github.com/twpayne/chezmoi/issues/new/choose) to discuss the -change that you want to make. Dave Cheney gives a [good -rationale](https://dave.cheney.net/2019/02/18/talk-then-code) as to why this is -important. - -All changes are made via pull requests. In your pull request, please make sure -that: - -* All existing tests pass. - -* There are appropriate additional tests that demonstrate that your PR works as - intended. - -* The documentation is updated, if necessary. For new features you should add an - entry in `docs/HOWTO.md` and a complete description in `docs/REFERENCE.md`. - -* All generated files are up to date. You can ensure this by running `make - generate` and including any modified files in your commit. - -* The code is correctly formatted, according to - [`gofumpt`](https://mvdan.cc/gofumpt/). You can ensure this by running `make - format`. - -* The code passes [`golangci-lint`](https://github.com/golangci/golangci-lint). - You can ensure this by running `make lint`. - -* The commit messages follow the [conventional commits - specification](https://www.conventionalcommits.org/en/v1.0.0/). - -* Commits are logically separate, with no merge or "fixup" commits. - -* The branch applies cleanly to `master`. - ---- - -## Managing releases - -Releases are managed with [`goreleaser`](https://goreleaser.com/). - -To build a test release, without publishing, (Linux only) run: - -```console -$ make test-release -``` - -Publish a new release by creating and pushing a tag, e.g.: - -```console -$ git tag v1.2.3 -$ git push --tags -``` - -This triggers a [GitHub Action](https://github.com/twpayne/chezmoi/actions) that -builds and publishes archives, packages, and snaps, and creates a new [GitHub -Release](https://github.com/twpayne/chezmoi/releases). - -Publishing [Snaps](https://snapcraft.io/) requires a `SNAPCRAFT_LOGIN` -[repository -secret](https://github.com/twpayne/chezmoi/settings/secrets/actions). Snapcraft -logins periodically expire. Create a new snapcraft login by running: - -```console -$ snapcraft export-login --snaps=chezmoi --channels=stable,candidate,beta,edge --acls=package_upload - -``` - -[brew](https://brew.sh/) automation will automatically detect new releases of -chezmoi within a few hours and open a pull request in -[https://github.com/Homebrew/homebrew-core](github.com/Homebrew/homebrew-core) -to bump the version. - -If needed, the pull request can be created with: - -```console -$ brew bump-formula-pr --tag=v1.2.3 chezmoi -``` - ---- - -## Building and installing with `make` - -chezmoi can be built with GNU make, assuming you have the Go toolchain -installed. - -Running `make` will build a `chezmoi` binary in the current directory for the -host OS and architecture. To embed version information in the binary and control -installation the following variables are available: - -| Variable | Example | Purpose | -| ----------- | ---------------------- | ----------------------------------------------- | -| `$VERSION` | `v2.0.0` | Set version. | -| `$COMMIT` | `3895680a`... | Set the git commit at which the code was built. | -| `$DATE` | `2019-11-23T18:29:25Z` | The time of the build. | -| `$BUILT_BY` | `homebrew` | The packaging system performing the build. | -| `$PREFIX` | `/usr` | Installation prefix. | -| `$DESTDIR` | `install-root` | Fake installation root. | - -Running `make install` will install the `chezmoi` binary in `${DESTDIR}${PREFIX}/bin`. - ---- - -## Packaging - -If you're packaging chezmoi for an operating system or distribution: - -* chezmoi has no build dependencies other than the standard Go toolchain. - -* chezmoi has no runtime dependencies, but is usually used with `git`, so many - packagers choose to make `git` an install dependency or recommended package. - -* Please set the version number, git commit, and build time in the binary. This - greatly assists debugging when end users report problems or ask for help. You - can do this by passing the following flags to `go build`: - - ``` - -ldflags "-X main.version=$VERSION - -X main.commit=$COMMIT - -X main.date=$DATE - -X main.builtBy=$BUILT_BY" - ``` - - `$VERSION` should be the chezmoi version, e.g. `1.7.3`. Any `v` prefix is - optional and will be stripped, so you can pass the git tag in directly. The - command `git describe --abbrev=0 --tags` will return a suitable value. - - `$COMMIT` should be the full git commit hash at which chezmoi is built, e.g. - `4d678ce6850c9d81c7ab2fe0d8f20c1547688b91`. The command `git rev-parse HEAD` - will return a suitable value. - - `$DATE` should be the date of the build as a UNIX timestamp or in RFC3339 - format. The command `git show -s --format=%ct HEAD` returns the UNIX timestamp - of the last commit, e.g. `1636668628`. The command `date -u - +%Y-%m-%dT%H:%M:%SZ` returns the current time in RFC3339 format, e.g. - `2019-11-23T18:29:25Z`. - - `$BUILT_BY` should be a string indicating what system was used to build the - binary. Typically it should be the name of your packaging system, e.g. - `homebrew`. - -* Please enable cgo, if possible. chezmoi can be built and run without cgo, but - the `.chezmoi.username` and `.chezmoi.group` template variables may not be set - correctly on some systems. - -* chezmoi includes an `upgrade` command which attempts to self-upgrade. You can - remove this command completely by building chezmoi with the `noupgrade` build - tag. - -* chezmoi includes shell completions in the `completions` directory. Please - include these in the package and install them in the shell-appropriate - directory, if possible. - -* If the instructions for installing chezmoi in chezmoi's [install - guide](https://github.com/twpayne/chezmoi/blob/master/docs/INSTALL.md) are - absent or incorrect, please open an issue or submit a PR to correct them. - ---- - -## Updating the website - -[The website](https://chezmoi.io) is generated with [Hugo](https://gohugo.io/) -and served with [GitHub pages](https://pages.github.com/) from the [`gh-pages` -branch](https://github.com/twpayne/chezmoi/tree/gh-pages) to GitHub. - -Before building the website, you must download the [Hugo Book -Theme](https://github.com/alex-shpak/hugo-book) by running: - -```console -$ git submodule update --init -``` - -Test the website locally by running: - -```console -$ ( cd assets/chezmoi.io && hugo serve ) -``` - -and visit http://localhost:1313/. - -To build the website in a temporary directory, run: - -```console -$ ( cd assets/chezmoi.io && make ) -``` - -From here you can run - -```console -$ git show -``` - -to show changes and - -```console -$ git push -``` - -to push them. You can only push changes if you have write permissions to the -chezmoi GitHub repo. - ---- diff --git a/docs/FAQ.md b/docs/FAQ.md deleted file mode 100644 index e144a0e8037..00000000000 --- a/docs/FAQ.md +++ /dev/null @@ -1,580 +0,0 @@ -# chezmoi frequently asked questions - - -* [How can I quickly check for problems with chezmoi on my machine?](#how-can-i-quickly-check-for-problems-with-chezmoi-on-my-machine) -* [How do I edit my dotfiles with chezmoi?](#how-do-i-edit-my-dotfiles-with-chezmoi) -* [Do I have to use `chezmoi edit` to edit my dotfiles?](#do-i-have-to-use-chezmoi-edit-to-edit-my-dotfiles) -* [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) -* [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) -* [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) -* [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) -* [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) -* [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) -* [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) -* [Why does chezmoi convert all my template variables to lowercase?](#why-does-chezmoi-convert-all-my-template-variables-to-lowercase) -* [chezmoi makes `~/.ssh/config` group writeable. How do I stop this?](#chezmoi-makes-sshconfig-group-writeable-how-do-i-stop-this) -* [Why does `chezmoi cd` spawn a shell instead of just changing directory?](#why-does-chezmoi-cd-spawn-a-shell-instead-of-just-changing-directory) -* [Why doesn't chezmoi use symlinks like GNU Stow?](#why-doesnt-chezmoi-use-symlinks-like-gnu-stow) -* [What are the limitations of chezmoi's symlink mode?](#what-are-the-limitations-of-chezmois-symlink-mode) -* [Can I change how chezmoi's source state is represented on disk?](#can-i-change-how-chezmois-source-state-is-represented-on-disk) - * [The output of `chezmoi diff` is broken and does not contain color. What could be wrong?](#the-output-of-chezmoi-diff-is-broken-and-does-not-contain-color-what-could-be-wrong) -* [gpg encryption fails. What could be wrong?](#gpg-encryption-fails-what-could-be-wrong) -* [chezmoi reports `chezmoi: user: lookup userid NNNNN: input/output error`](#chezmoi-reports-chezmoi-user-lookup-userid-nnnnn-inputoutput-error) -* [chezmoi reports `chezmoi: timeout` or `chezmoi: timeout obtaining persistent state lock`](#chezmoi-reports-chezmoi-timeout-or-chezmoi-timeout-obtaining-persistent-state-lock) -* [I'm getting errors trying to build chezmoi from source](#im-getting-errors-trying-to-build-chezmoi-from-source) -* [What inspired chezmoi?](#what-inspired-chezmoi) -* [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) -* [Can I use chezmoi to manage files outside my home directory?](#can-i-use-chezmoi-to-manage-files-outside-my-home-directory) -* [Where does the name "chezmoi" come from?](#where-does-the-name-chezmoi-come-from) -* [What other questions have been asked about chezmoi?](#what-other-questions-have-been-asked-about-chezmoi) -* [Where do I ask a question that isn't answered here?](#where-do-i-ask-a-question-that-isnt-answered-here) -* [I like chezmoi. How do I say thanks?](#i-like-chezmoi-how-do-i-say-thanks) - ---- - -## How can I quickly check for problems with chezmoi on my machine? - -Run: - -```console -$ chezmoi doctor -``` - -Anything `ok` is fine, anything `warning` is only a problem if you want to use -the related feature, and anything `error` indicates a definite problem. - ---- - -## How do I edit my dotfiles with chezmoi? - -There are four popular approaches: - -1. Use `chezmoi edit $FILE`. This will open the source file for `$FILE` in your - editor, including . For extra ease, use `chezmoi edit --apply $FILE` to apply - the changes when you quit your editor. -2. Use `chezmoi cd` and edit the files in the source directory directly. Run - `chezmoi diff` to see what changes would be made, and `chezmoi apply` to make - the changes. -3. If your editor supports opening directories, run `chezmoi edit` with no - arguments to open the source directory. -4. Edit the file in your home directory, and then either re-add it by running - `chezmoi add $FILE` or `chezmoi re-add`. Note that `re-add` doesn't work with - templates. -5. Edit the file in your home directory, and then merge your changes with source - state by running `chezmoi merge $FILE`. - ---- - -## Do I have to use `chezmoi edit` to edit my dotfiles? - -No. `chezmoi edit` is a convenience command that has a couple of useful -features, but you don't have to use it. You can also run `chezmoi cd` and then -just edit the files in the source state directly. After saving an edited file -you can run `chezmoi diff` to check what effect the changes would have, and run -`chezmoi apply` if you're happy with them. - -`chezmoi edit` provides the following useful features: -* It opens the correct file in the source state for you with a filename matching - the target filename, so your editor's syntax highlighting will work and you - don't have to know anything about source state attributes. -* If the dotfile is encrypted in the source state, then `chezmoi edit` will - decrypt it to a private directory, open that file in your `$EDITOR`, and then - re-encrypt the file when you quit your editor. That makes encryption more - transparent to the user. With the `--diff` and `--apply` options you can see - what would change and apply those changes without having to run `chezmoi diff` - or `chezmoi apply`. Note also that the arguments to `chezmoi edit` are the - files in their target location. - ---- - -## 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? - -Until you run `chezmoi apply` your modified `~/.zshrc` will remain in place. -When you run `chezmoi apply` chezmoi will detect that `~/.zshrc` has changed -since chezmoi last wrote it and prompt you what to do. You can resolve -differences with a merge tool by running `chezmoi merge ~/.zshrc`. - ---- - -## 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? - -`chezmoi unmanaged` will list everything not managed by chezmoi. You can add -entire directories with `chezmoi add`. - ---- - -## How can I tell what dotfiles in my home directory are currently managed by chezmoi? - -`chezmoi managed` will list everything managed by chezmoi. - ---- - -## 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)? - -By default, chezmoi ignores everything that you haven't explicitly added. If you -have files in your source directory that you don't want added to your -destination directory when you run `chezmoi apply` add their names to a file -called `.chezmoiignore` in the source state. - -Patterns are supported, and you can change what's ignored from machine to -machine. The full usage and syntax is described in the [reference -manual](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#chezmoiignore). - ---- - -## 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? - -Yes. Run `chezmoi add` will update the source state with the target. To see -diffs of what would change, without actually changing anything, use `chezmoi -diff`. - ---- - -## Once I've made a change to the source directory, how do I commit it? - -You have several options: - -* `chezmoi cd` opens a shell in the source directory, where you can run your - usual version control commands, like `git add` and `git commit`. -* `chezmoi git` runs `git` in the source - directory and pass extra arguments to the command. If you're passing any - flags, you'll need to use `--` to prevent chezmoi from consuming them, for - example `chezmoi git -- commit -m "Update dotfiles"`. -* You can configure chezmoi to automatically commit and push changes to your - source state, as [described in the how-to - guide](https://github.com/twpayne/chezmoi/blob/master/docs/HOWTO.md#automatically-commit-and-push-changes-to-your-repo). - ---- - -## I've made changes to both the destination state and the source state that I want to keep. How can I keep them both? - -`chezmoi merge` will open a merge tool to resolve differences between the source -state, target state, and destination state. Copy the changes you want to keep in -to the source state. - ---- - -## Why does chezmoi convert all my template variables to lowercase? - -This is due to a feature in -[`github.com/spf13/viper`](https://github.com/spf13/viper), the library that -chezmoi uses to read its configuration file. For more information see [this -GitHub issue](https://github.com/twpayne/chezmoi/issues/463). - ---- - -## chezmoi makes `~/.ssh/config` group writeable. How do I stop this? - -By default, chezmoi uses your system's umask when creating files. On most -systems the default umask is `022` but some systems use `002`, which means -that files and directories are group writeable by default. - -You can override this for chezmoi by setting the `umask` configuration variable -in your configuration file, for example: - -```toml -umask = 0o022 -``` - -Note that this will apply to all files and directories that chezmoi manages and -will ensure that none of them are group writeable. It is not currently possible -to control group write permissions for individual files or directories. Please -[open an issue on -GitHub](https://github.com/twpayne/chezmoi/issues/new?assignees=&labels=enhancement&template=02_feature_request.md&title=) -if you need this. - ---- - -## Why does `chezmoi cd` spawn a shell instead of just changing directory? - -`chezmoi cd` spawns a shell because it is not possible for a program to change -the working directory of its parent process. You can add a shell function instead: - -```bash -chezmoi-cd() { - cd $(chezmoi source-path) -} -``` - -Typing `chezmoi-cd` will then change the directory of your current shell to -chezmoi's source directory. - ---- - -## Why doesn't chezmoi use symlinks like GNU Stow? - -Symlinks are first class citizens in chezmoi: chezmoi supports creating them, -updating them, removing them, and even more advanced features not found -elsewhere like having the same symlink point to different targets on different -machines by using a template. - -With chezmoi, you only use a symlink where you really need a symlink, in -contrast to some other dotfile managers (e.g. GNU Stow) which require the use of -symlinks as a layer of indirection between a dotfile's location (which can be -anywhere in your home directory) and a dotfile's content (which needs to be in a -centralized directory that you manage with version control). chezmoi solves this -problem in a different way. - -Instead of using a symlink to redirect from the dotfile's location to the -centralized directory, chezmoi generates the dotfile as a regular file in its -final location from the contents of the centralized directory. This approach -allows chezmoi to provide features that are not possible when using symlinks, -for example having files that are encrypted, executable, private, or templates. - -There's nothing special about dotfiles managed by chezmoi, whereas dotfiles -managed with GNU Stow are special because they're actually symlinks to somewhere -else. - -The only advantage to using GNU Stow-style symlinks is that changes that you -make to the dotfile's contents in the centralized directory are immediately -visible, whereas chezmoi currently requires you to run `chezmoi apply` or -`chezmoi edit --apply`. chezmoi will likely get an alternative solution to this -too, see [#752](https://github.com/twpayne/chezmoi/issues/752). - -If you really want to use symlinks, then chezmoi provides a [symlink -mode](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#symlink-mode) -which uses symlinks where possible. - -You can configure chezmoi to work like GNU Stow and have it create a set of -symlinks back to a central directory, but this currently requires a bit of -manual work (as described in -[#167](https://github.com/twpayne/chezmoi/issues/167)). chezmoi might get some -automation to help (see [#886](https://github.com/twpayne/chezmoi/issues/886) -for example) but it does need some convincing use cases that demonstrate that a -symlink from a dotfile's location to its contents in a central directory is -better than just having the correct dotfile contents. - ---- - -## What are the limitations of chezmoi's symlink mode? - -In symlink mode chezmoi replaces targets with symlinks to the source directory -if the the target is a regular file and is not encrypted, executable, private, -or a template. - -Symlinks cannot be used for encrypted files because the source state contains -the ciphertext, not the plaintext. - -Symlinks cannot be used for executable files as the executable bit would need to -be set on the file in the source directory and chezmoi uses only regular files -and directories in its source state for portability across operating systems. -This may change in the future. - -Symlinks cannot be used for private files because git does not persist group and -world permission bits. - -Symlinks cannot be used for templated files because the source state contains -the template, not the result of executing the template. - -Symlinks cannot be used for entire directories because of chezmoi's use of -attributes in the filename mangles entries in the directory, directories might -have the `exact_` attribute and contain empty files, and the directory's entries -might not be usable with symlinks. - -In symlink mode, running `chezmoi add` does not immediately replace the targets -with a symlink. You must run `chezmoi apply` to create the symlinks. - ---- - -## Can I change how chezmoi's source state is represented on disk? - -There are a number of criticisms of how chezmoi's source state is represented on -disk: - -1. Not all possible file permissions can be represented. -2. The long source file names are weird and verbose. -3. Everything is in a single directory, which can end up containing many entries. - -chezmoi's source state representation is a deliberate, practical compromise. - -The `dot_` attribute makes it transparent which dotfiles are managed by chezmoi -and which files are ignored by chezmoi. chezmoi ignores all files and -directories that start with `.` so no special whitelists are needed for version -control systems and their control files (e.g. `.git` and `.gitignore`). - -chezmoi needs per-file metadata to know how to interpret the source file's -contents, for example to know when the source file is a template or if the -file's contents are encrypted. By storing this metadata in the filename, the -metadata is unambiguously associated with a single file and adding, updating, or -removing a single file touches only a single file in the source state. Changes -to the metadata (e.g. `chezmoi chattr +template *target*`) are simple file -renames and isolated to the affected file. - -If chezmoi were to, say, use a common configuration file listing which files -were templates and/or encrypted, then changes to any file would require updates -to the common configuration file. Automating updates to configuration files -requires a round trip (read config file, update config, write config) and it is -not always possible preserve comments and formatting. - -chezmoi's attributes of `executable_`, `private_`, and `readonly_` allow a the -file permissions `0o644`, `0o755`, `0o600`, `0o700`, `0o444`, `0o555`, `0o400`, -and `0o500` to be represented. Directories can only have permissions `0o755`, -`0o700`, or `0o500`. In practice, these cover all permissions typically used for -dotfiles. If this does cause a genuine problem for you, please [open an issue on -GitHub](https://github.com/twpayne/chezmoi/issues/new/choose). - -File permissions and modes like `executable_`, `private_`, `readonly_`, and -`symlink_` could also be stored in the filesystem, rather than in the filename. -However, this requires the permissions to be preserved and handled by the -underlying version control system and filesystem. chezmoi provides first-class -support for Windows, where the `executable_` and `private_` attributes have no -direct equivalents and symbolic links are not always permitted. By using regular -files and directories, chezmoi avoids variations in the operating system, -version control system, and filesystem making it both more robust and more -portable. - -chezmoi uses a 1:1 mapping between entries in the source state and entries in -the target state. This mapping is bi-directional and unambiguous. - -However, this also means that dotfiles that in the same directory in the target -state must be in the same directory in the source state. In particular, every -entry managed by chezmoi in the root of your home directory has a corresponding -entry in the root of your source directory, which can mean that you end up with -a lot of entries in the root of your source directory. - -If chezmoi were to permit, say, multiple separate source directories (so you -could, say, put `dot_bashrc` in a `bash/` subdirectory, and `dot_vimrc` in a -`vim/` subdirectory, but have `chezmoi apply` map these to `~/.bashrc` and -`~/.vimrc` in the root of your home directory) then the mapping between source -and target states is no longer bidirectional nor unambiguous, which -significantly increases complexity and requires more user interaction. For -example, if both `bash/dot_bashrc` and `vim/dot_bashrc` exist, what should be -the contents of `~/.bashrc`? If you run `chezmoi add ~/.zshrc`, should -`dot_zshrc` be stored in the source `bash/` directory, the source `vim/` -directory, or somewhere else? How does the user communicate their preferences? - -chezmoi has many users and any changes to the source state representation must -be backwards-compatible. - -In summary, chezmoi's source state representation is a compromise with both -advantages and disadvantages. Changes to the representation will be considered, -but must meet the following criteria, in order of importance: - -1. Be fully backwards-compatible for existing users. -2. Fix a genuine problem encountered in practice. -3. Be independent of the underlying operating system, version control system, - and filesystem. -4. Not add significant extra complexity to the user interface or underlying - implementation. - ---- - -### The output of `chezmoi diff` is broken and does not contain color. What could be wrong? - -By default, chezmoi's diff output includes ANSI color escape sequences (e.g. -`ESC[37m`) and is piped into your pager (by default `less`). chezmoi assumes -that your pager passes through the ANSI color escape sequences, as configured on -many systems, but not all. If your pager does not pass through ANSI color escape -sequences then you will see monochrome diff output with uninterpreted ANSI color -escape sequences. - -This can typically by fixed by setting the environment variable - -```console -$ export LESS=-R -``` - -which instructs `less` to display "raw" control characters via the `-R` / -`--RAW-CONTROL-CHARS` option. - -You can also set the `pager` configuration variable in your config file, for -example: - -```toml -pager = "less -R" -``` - -If you have set a different pager (via the `pager` configuration variable or -`PAGER` environment variable) then you must ensure that it passes through raw -control characters. Alternatively, you can use the `--color=false` option to -chezmoi to disable colors or the `--no-pager` option to chezmoi to disable the -pager. - ---- - -## gpg encryption fails. What could be wrong? - -The `gpg.recipient` key should be ultimately trusted, otherwise encryption will -fail because gpg will prompt for input, which chezmoi does not handle. You can -check the trust level by running: - -```console -$ gpg --export-ownertrust -``` - -The trust level for the recipient's key should be `6`. If it is not, you can -change the trust level by running: - -```console -$ gpg --edit-key $recipient -``` - -Enter `trust` at the prompt and chose `5 = I trust ultimately`. - ---- - -## chezmoi reports `chezmoi: user: lookup userid NNNNN: input/output error` - -This is likely because the chezmoi binary you are using was statically compiled -with [musl](https://musl.libc.org/) and the machine you are running on uses -LDAP or NIS. - -The immediate fix is to use a package built for your distribution (e.g a `.deb` -or `.rpm`) which is linked against glibc and includes LDAP/NIS support instead -of the statically-compiled binary. - -If the problem still persists, then please [open an issue on -GitHub](https://github.com/twpayne/chezmoi/issues/new/choose). - ---- - -## chezmoi reports `chezmoi: timeout` or `chezmoi: timeout obtaining persistent state lock` - -chezmoi will report this when it is unable to lock its persistent state -(`~/.config/chezmoi/chezmoistate.boltdb`), typically because another instance of -chezmoi is currently running and holding the lock. - -This can happen, for example, if you have a `run_` script that invokes -`chezmoi`, or are running chezmoi in another window. - -Under the hood, chezmoi uses [bbolt](https://github.com/etcd-io/bbolt) which -permits multiple simultaneous readers, but only one writer (with no readers). - -Commands that take a write lock include `add`, `apply`, `edit`, `forget`, -`import`, `init`, `state`, `unmanage`, and `update`. Commands that take a read -lock include `diff`, `status`, and `verify`. - ---- - -## I'm getting errors trying to build chezmoi from source - -chezmoi requires Go version 1.16 or later. You can check the version of Go with: - -```console -$ go version -``` - -If you try to build chezmoi with an earlier version of Go you will get the error: - -``` -package github.com/twpayne/chezmoi/v2: build constraints exclude all Go files in /home/twp/src/github.com/twpayne/chezmoi -``` - -This is because chezmoi includes the build tag `go1.16` in `main.go`, which is -only set on Go 1.16 or later. - -For more details on building chezmoi, see the [Contributing -Guide]([CONTRIBUTING.md](https://github.com/twpayne/chezmoi/blob/master/docs/CONTRIBUTING.md)). - ---- - -## What inspired chezmoi? - -chezmoi was inspired by [Puppet](https://puppet.com/), but was created because -Puppet is an overkill for managing your personal configuration files. The focus -of chezmoi will always be personal home directory management. If your needs grow -beyond that, switch to a whole system configuration management tool. - ---- - -## Why not use Ansible/Chef/Puppet/Salt, or similar to manage my dotfiles instead? - -Whole system management tools are more than capable of managing your dotfiles, -but are large systems that entail several disadvantages. Compared to whole -system management tools, chezmoi offers: - -* Small, focused feature set designed for dotfiles. There's simply less to learn - with chezmoi compared to whole system management tools. -* Easy installation and execution on every platform, without root access. - Installing chezmoi requires only copying a single binary file with no external - dependencies. Executing chezmoi just involves running the binary. In contrast, - installing and running a whole system management tool typically requires - installing a scripting language runtime, several packages, and running a - system service, all typically requiring root access. - -chezmoi's focus and simple installation means that it runs almost everywhere: -from tiny ARM-based Linux systems to Windows desktops, from inside lightweight -containers to FreeBSD-based virtual machines in the cloud. - ---- - -## Can I use chezmoi to manage files outside my home directory? - -In practice, yes, you can, but this is strongly discouraged beyond using your -system's package manager to install the packages you need. - -chezmoi is designed to operate on your home directory, and is explicitly not a -full system configuration management tool. That said, there are some ways to -have chezmoi manage a few files outside your home directory. - -chezmoi's scripts can execute arbitrary commands, so you can use a `run_` script -that is run every time you run `chezmoi apply`, to, for example: - -* Make the target file outside your home directory a symlink to a file managed - by chezmoi in your home directory. -* Copy a file managed by chezmoi inside your home directory to the target file. -* Execute a template with `chezmoi execute-template --output=filename template` - where `filename` is outside the target directory. - -chezmoi executes all scripts as the user executing chezmoi, so you may need to -add extra privilege elevation commands like `sudo` or `PowerShell start -verb -runas -wait` to your script. - -chezmoi, by default, operates on your home directory but this can be overridden -with the `--destination` command line flag or by specifying `destDir` in your -config file, and could even be the root directory (`/` or `C:\`). This allows -you, in theory, to use chezmoi to manage any file in your filesystem, but this -usage is extremely strongly discouraged. - -If your needs extend beyond modifying a handful of files outside your target -system, then existing configuration management tools like -[Puppet](https://puppet.com/), [Chef](https://chef.io/), -[Ansible](https://www.ansible.com/), and [Salt](https://www.saltstack.com/) are -much better suited - and of course can be called from a chezmoi `run_` script. -Put your Puppet Manifests, Chef Recipes, Ansible Modules, and Salt Modules in a -directory ignored by `.chezmoiignore` so they do not pollute your home -directory. - ---- - -## Where does the name "chezmoi" come from? - -"chezmoi" splits to "chez moi" and pronounced /ʃeɪ mwa/ (shay-moi) meaning "at -my house" in French. It's seven letters long, which is an appropriate length for -a command that is only run occasionally. - ---- - -## What other questions have been asked about chezmoi? - -See the [issues on -GitHub](https://github.com/twpayne/chezmoi/issues?utf8=%E2%9C%93&q=is%3Aissue+sort%3Aupdated-desc+label%3Asupport). - ---- - -## Where do I ask a question that isn't answered here? - -Please [open an issue on GitHub](https://github.com/twpayne/chezmoi/issues/new/choose). - ---- - -## I like chezmoi. How do I say thanks? - -Thank you! chezmoi was written to scratch a personal itch, and I'm very happy -that it's useful to you. Please give [chezmoi a star on -GitHub](https://github.com/twpayne/chezmoi/stargazers), and if you're happy to -share your public dotfile repo then [tag it with -`chezmoi`](https://github.com/topics/chezmoi?o=desc&s=updated). - -If you write an article or give a talk on chezmoi please inform the author (e.g. -by [opening an issue](https://github.com/twpayne/chezmoi/issues/new/choose)) so -it can be added to chezmoi's [media -page](https://github.com/twpayne/chezmoi/blob/master/docs/MEDIA.md). - -[Contributions are very -welcome](https://github.com/twpayne/chezmoi/blob/master/docs/CONTRIBUTING.md) -and every [bug report, support request, and feature -request](https://github.com/twpayne/chezmoi/issues/new/choose) helps make -chezmoi better. Thank you :) - ---- diff --git a/docs/HOWTO.md b/docs/HOWTO.md deleted file mode 100644 index bff7dd1783d..00000000000 --- a/docs/HOWTO.md +++ /dev/null @@ -1,1755 +0,0 @@ -# chezmoi how-to guide - - -* [Perform daily operations](#perform-daily-operations) - * [Use a hosted repo to manage your dotfiles across multiple machines](#use-a-hosted-repo-to-manage-your-dotfiles-across-multiple-machines) - * [Use a private repo to store your dotfiles](#use-a-private-repo-to-store-your-dotfiles) - * [Pull the latest changes from your repo and apply them](#pull-the-latest-changes-from-your-repo-and-apply-them) - * [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) - * [Automatically commit and push changes to your repo](#automatically-commit-and-push-changes-to-your-repo) - * [Install chezmoi and your dotfiles on a new machine with a single command](#install-chezmoi-and-your-dotfiles-on-a-new-machine-with-a-single-command) -* [Manage different types of file](#manage-different-types-of-file) - * [Have chezmoi create a directory, but ignore its contents](#have-chezmoi-create-a-directory-but-ignore-its-contents) - * [Ensure that a target is removed](#ensure-that-a-target-is-removed) - * [Manage part, but not all, of a file](#manage-part-but-not-all-of-a-file) - * [Manage a file's permissions, but not its contents](#manage-a-files-permissions-but-not-its-contents) - * [Populate `~/.ssh/authorized_keys` with your public SSH keys from GitHub](#populate-sshauthorized_keys-with-your-public-ssh-keys-from-github) -* [Integrate chezmoi with your editor](#integrate-chezmoi-with-your-editor) - * [Use your preferred editor with `chezmoi edit` and `chezmoi edit-config`](#use-your-preferred-editor-with-chezmoi-edit-and-chezmoi-edit-config) - * [Configure VIM to run `chezmoi apply` whenever you save a dotfile](#configure-vim-to-run-chezmoi-apply-whenever-you-save-a-dotfile) -* [Include dotfiles from elsewhere](#include-dotfiles-from-elsewhere) - * [Include a subdirectory from another repository, like Oh My Zsh](#include-a-subdirectory-from-another-repository-like-oh-my-zsh) - * [Include a single file from another repository](#include-a-single-file-from-another-repository) - * [Handle configuration files which are externally modified](#handle-configuration-files-which-are-externally-modified) - * [Import archives](#import-archives) -* [Manage machine-to-machine differences](#manage-machine-to-machine-differences) - * [Use templates](#use-templates) - * [Ignore files or a directory on different machines](#ignore-files-or-a-directory-on-different-machines) - * [Use completely different dotfiles on different machines](#use-completely-different-dotfiles-on-different-machines) - * [Create a config file on a new machine automatically](#create-a-config-file-on-a-new-machine-automatically) - * [Re-create your config file](#re-create-your-config-file) - * [Handle different file locations on different systems with the same contents](#handle-different-file-locations-on-different-systems-with-the-same-contents) - * [Create an archive of your dotfiles](#create-an-archive-of-your-dotfiles) -* [Keep data private](#keep-data-private) - * [Use 1Password](#use-1password) - * [Use Bitwarden](#use-bitwarden) - * [Use gopass](#use-gopass) - * [Use KeePassXC](#use-keepassxc) - * [Use Keychain or Windows Credentials Manager](#use-keychain-or-windows-credentials-manager) - * [Use LastPass](#use-lastpass) - * [Use pass](#use-pass) - * [Use Vault](#use-vault) - * [Use a custom password manager](#use-a-custom-password-manager) - * [Encrypt whole files with gpg](#encrypt-whole-files-with-gpg) - * [Encrypt whole files with age](#encrypt-whole-files-with-age) - * [Use a private configuration file and template variables](#use-a-private-configuration-file-and-template-variables) -* [Use scripts to perform actions](#use-scripts-to-perform-actions) - * [Understand how scripts work](#understand-how-scripts-work) - * [Install packages with scripts](#install-packages-with-scripts) - * [Run a script when the contents of another file changes](#run-a-script-when-the-contents-of-another-file-changes) -* [Use chezmoi on macOS](#use-chezmoi-on-macos) - * [Use `brew bundle` to manage your brews and casks](#use-brew-bundle-to-manage-your-brews-and-casks) -* [Use chezmoi on Windows](#use-chezmoi-on-windows) - * [Detect Windows Subsystem for Linux (WSL)](#detect-windows-subsystem-for-linux-wsl) - * [Run a PowerShell script as admin on Windows](#run-a-powershell-script-as-admin-on-windows) -* [Use chezmoi with GitHub Codespaces, Visual Studio Codespaces, or Visual Studio Code Remote - Containers](#use-chezmoi-with-github-codespaces-visual-studio-codespaces-or-visual-studio-code-remote---containers) -* [Customize chezmoi](#customize-chezmoi) - * [Use a subdirectory of your dotfiles repo as the root of the source state](#use-a-subdirectory-of-your-dotfiles-repo-as-the-root-of-the-source-state) - * [Don't show scripts in the diff output](#dont-show-scripts-in-the-diff-output) - * [Customize the diff pager](#customize-the-diff-pager) - * [Use a custom diff tool](#use-a-custom-diff-tool) - * [Use a custom merge tool](#use-a-custom-merge-tool) - * [Use an HTTP or SOCKS5 proxy](#use-an-http-or-socks5-proxy) -* [Migrating to chezmoi from another dotfile manager](#migrating-to-chezmoi-from-another-dotfile-manager) - * [Migrate from a dotfile manager that uses symlinks](#migrate-from-a-dotfile-manager-that-uses-symlinks) -* [Migrate away from chezmoi](#migrate-away-from-chezmoi) - ---- - -## Perform daily operations - ---- - -### Use a hosted repo to manage your dotfiles across multiple machines - -chezmoi relies on your version control system and hosted repo to share changes -across multiple machines. You should create a repo on the source code repository -of your choice (e.g. [Bitbucket](https://bitbucket.org), -[GitHub](https://github.com/), or [GitLab](https://gitlab.com), many people call -their repo `dotfiles`) and push the repo in the source directory here. For -example: - -```console -$ chezmoi cd -$ git remote add origin https://github.com/username/dotfiles.git -$ git push -u origin main -$ exit -``` - -On another machine you can checkout this repo: - -```console -$ chezmoi init https://github.com/username/dotfiles.git -``` - -You can then see what would be changed: - -```console -$ chezmoi diff -``` - -If you're happy with the changes then apply them: - -```console -$ chezmoi apply -``` - -The above commands can be combined into a single init, checkout, and apply: - -```console -$ chezmoi init --apply --verbose https://github.com/username/dotfiles.git -``` - ---- - -### Use a private repo to store your dotfiles - -chezmoi supports storing your dotfiles in both public and private repos. - -chezmoi is designed so that your dotfiles repo can be public by making it easy -for you to store your secrets either in your password manager, in encrypted -files, or in private configuration files. Your dotfiles repo can still be -private, if you choose. - -If you use a private repo for your dotfiles then you will typically need to -enter your credentials (e.g. your username and password) each time you interact -with the repo, for example when pulling or pushing changes. chezmoi itself does -not store any credentials, but instead relies on your local git configuration -for these operations. - -When using a private repo on GitHub, when prompted for a password you will need -to enter a [GitHub personal access -token](https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token). -For more information on these changes, read the [GitHub blog post on Token -authentication requirements for Git -operations](https://github.blog/2020-12-15-token-authentication-requirements-for-git-operations/) - ---- - -### Pull the latest changes from your repo and apply them - -You can pull the changes from your repo and apply them in a single command: - -```console -$ chezmoi update -``` - -This runs `git pull --rebase` in your source directory and then `chezmoi apply`. - ---- - -### Pull the latest changes from your repo and see what would change, without actually applying the changes - -Run: - -```console -$ chezmoi git pull -- --rebase && chezmoi diff -``` - -This runs `git pull --rebase` in your source directory and `chezmoi -diff` then shows the difference between the target state computed from your -source directory and the actual state. - -If you're happy with the changes, then you can run - -```console -$ chezmoi apply -``` - -to apply them. - ---- - -### Automatically commit and push changes to your repo - -chezmoi can automatically commit and push changes to your source directory to -your repo. This feature is disabled by default. To enable it, add the following -to your config file: - -```toml -[git] - autoCommit = true - autoPush = true -``` - -Whenever a change is made to your source directory, chezmoi will commit the -changes with an automatically-generated commit message (if `autoCommit` is true) -and push them to your repo (if `autoPush` is true). `autoPush` implies -`autoCommit`, i.e. if `autoPush` is true then chezmoi will auto-commit your -changes. If you only set `autoCommit` to true then changes will be committed but -not pushed. - -Be careful when using `autoPush`. If your dotfiles repo is public and you -accidentally add a secret in plain text, that secret will be pushed to your -public repo. - ---- - -### Install chezmoi and your dotfiles on a new machine with a single command - -chezmoi's install script can run `chezmoi init` for you by passing extra -arguments to the newly installed chezmoi binary. If your dotfiles repo is -`github.com//dotfiles` then installing chezmoi, running -`chezmoi init`, and running `chezmoi apply` can be done in a single line of -shell: - -```console -$ sh -c "$(curl -fsLS git.io/chezmoi)" -- init --apply -``` - -If your dotfiles repo has a different name to `dotfiles`, or if you host your -dotfiles on a different service, then see the [reference manual for `chezmoi -init`](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#init-repo). - -For setting up transitory environments (e.g. short-lived Linux containers) you -can install chezmoi, install your dotfiles, and then remove all traces of -chezmoi, including the source directory and chezmoi's configuration directory, -with a single command: - -```console -$ sh -c "$(curl -fsLS git.io/chezmoi)" -- init --one-shot -``` - ---- - -## Manage different types of file - ---- - -### Have chezmoi create a directory, but ignore its contents - -If you want chezmoi to create a directory, but ignore its contents, say `~/src`, -first run: - -```console -$ mkdir -p $(chezmoi source-path)/src -``` - -This creates the directory in the source state, which means that chezmoi will -create it (if it does not already exist) when you run `chezmoi apply`. - -However, as this is an empty directory it will be ignored by git. So, create a -file in the directory in the source state that will be seen by git (so git does -not ignore the directory) but ignored by chezmoi (so chezmoi does not include it -in the target state): - -```console -$ touch $(chezmoi source-path)/src/.keep -``` - -chezmoi automatically creates `.keep` files when you add an empty directory with -`chezmoi add`. - ---- - -### Ensure that a target is removed - -Create a file called `.chezmoiremove` in the source directory containing a list -of patterns of files to remove. chezmoi will remove anything in the target -directory that matches the pattern. As this command is potentially dangerous, -you should run chezmoi in verbose, dry-run mode beforehand to see what would be -removed: - -```console -$ chezmoi apply --dry-run --verbose -``` - -`.chezmoiremove` is interpreted as a template, so you can remove different files -on different machines. Negative matches (patterns prefixed with a `!`) or -targets listed in `.chezmoiignore` will never be removed. - ---- - -### Manage part, but not all, of a file - -chezmoi, by default, manages whole files, but there are two ways to manage just -parts of a file. - -Firstly, a `modify_` script receives the current contents of the file on the -standard input and chezmoi reads the target contents of the file from the -script's standard output. This can be used to change parts of a file, for -example using `sed`. Note that if the file does not exist then the standard -input to the `modify_` script will be empty and it is the script's -responsibility to write a complete file to the standard output. - -Secondly, if only a small part of the file changes then consider using a -template to re-generate the full contents of the file from the current state. -For example, Kubernetes configurations include a current context that can be -substituted with: - -``` -current-context: {{ output "kubectl" "config" "current-context" | trim }} -``` - ---- - -### Manage a file's permissions, but not its contents - -chezmoi's `create_` attributes allows you to tell chezmoi to create a file if it -does not already exist. chezmoi, however, will apply any permission changes from -the `executable_`, `private_`, and `readonly_` attributes. This can be used to -control a file's permissions without altering its contents. - -For example, if you want to ensure that `~/.kube/config` always has permissions -600 then if you create an empty file called `dot_kube/private_dot_config` in -your source state, chezmoi will ensure `~/.kube/config`'s permissions are 0600 -when you run `chezmoi apply` without changing its contents. - -This approach does have the downside that chezmoi will create the file if it -does not already exist. If you only want `chezmoi apply` to set a file's -permissions if it already exists and not create the file otherwise, you can use -a `run_` script. For example, create a file in your source state called -`run_set_kube_config_permissions.sh` containing: - -```bash -#!/bin/sh - -FILE="$HOME/.kube/config" -if [ -f "$FILE" ]; then - if [ "$(stat -c %a "$FILE")" != "600" ] ; then - chmod 600 "$FILE" - fi -fi -``` - ---- - -### Populate `~/.ssh/authorized_keys` with your public SSH keys from GitHub - -chezmoi can retrieve your public SSH keys from GitHub, which can be useful for -populating your `~/.ssh/authorized_keys`. Put the following in your -`~/.local/share/chezmoi/dot_ssh/authorized_keys.tmpl`, where `username` is your -GitHub username: - -``` -{{ range (gitHubKeys "username") -}} -{{ .Key }} -{{ end -}} -``` - ---- - -## Integrate chezmoi with your editor - ---- - -### Use your preferred editor with `chezmoi edit` and `chezmoi edit-config` - -By default, chezmoi will use your preferred editor as defined by the `$VISUAL` -or `$EDITOR` environment variables, falling back to a default editor depending -on your operating system (`vi` on UNIX-like operating systems, `notepad.exe` on -Windows). - -You can configure chezmoi to use your preferred editor by either setting the -`$EDITOR` environment variable or setting the `edit.command` variable in your -configuration file. - -The editor command must only return when you have finished editing the files. -chezmoi will emit a warning if your editor command returns too quickly. - -In the specific case of using [VSCode](https://code.visualstudio.com/) or -[Codium](https://vscodium.com/) as your editor, you must pass the `--wait` flag, -for example, in your shell config: - -```console -$ export EDITOR="code --wait" -``` - -Or in chezmoi's configuration file: - -```toml -[edit] - command = "code" - args = ["--wait"] -``` - ---- - -### Configure VIM to run `chezmoi apply` whenever you save a dotfile - -Put the following in your `.vimrc`: - -```vim -autocmd BufWritePost ~/.local/share/chezmoi/* ! chezmoi apply --source-path "%" -``` - ---- - -## Include dotfiles from elsewhere - ---- - -### Include a subdirectory from another repository, like Oh My Zsh - -To include a subdirectory from another repository, e.g. [Oh My -Zsh](https://github.com/ohmyzsh/ohmyzsh), you cannot use git submodules because -chezmoi uses its own format for the source state and Oh My Zsh is not -distributed in this format. Instead, you can use the `.chezmoiexternal.` -to tell chezmoi to import dotfiles from an external source. - -For example, to import Oh My Zsh, the [zsh-syntax-highlighting -plugin](https://github.com/zsh-users/zsh-syntax-highlighting), and -[powerlevel10k](https://github.com/romkatv/powerlevel10k), put the following in -`~/.local/share/chezmoi/.chezmoiexternal.toml`: - -```toml -[".oh-my-zsh"] - type = "archive" - url = "https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz" - exact = true - stripComponents = 1 - refreshPeriod = "168h" -[".oh-my-zsh/custom/plugins/zsh-syntax-highlighting"] - type = "archive" - url = "https://github.com/zsh-users/zsh-syntax-highlighting/archive/master.tar.gz" - exact = true - stripComponents = 1 - refreshPeriod = "168h" -[".oh-my-zsh/custom/themes/powerlevel10k"] - type = "archive" - url = "https://github.com/romkatv/powerlevel10k/archive/v1.15.0.tar.gz" - exact = true - stripComponents = 1 -``` - -To apply the changes, run: - -```console -$ chezmoi apply -``` - -chezmoi will download the archives and unpack them as if they were part of the -source state. chezmoi caches downloaded archives locally to avoid re-downloading -them every time you run a chezmoi command, and will only re-download them at -most every `refreshPeriod` (default never). - -In the above example `refreshPeriod` is set to `168h` (one week) for -`.oh-my-zsh` and `.oh-my-zsh/custom/plugins/zsh-syntax-highlighting` because the -URL point to tarballs of the `master` branch, which changes over time. No -refresh period is set for `.oh-my-zsh/custom/themes/powerlevel10k` because the -URL points to the a tarball of a tagged version, which does not change over -time. To bump the version of powerlevel10k, change the version in the URL. - -To force a refresh the downloaded archives, use the `--refresh-externals` flag -to `chezmoi apply`: - -```console -$ chezmoi --refresh-externals apply -``` - -`--refresh-externals` can be shortened to `-R`: - -```console -$ chezmoi -R apply -``` - -When using Oh My Zsh, make sure you disable auto-updates by setting -`DISABLE_AUTO_UPDATE="true"` in `~/.zshrc`. Auto updates will cause the -`~/.oh-my-zsh` directory to drift out of sync with chezmoi's source state. To -update Oh My Zsh and its plugins, refresh the downloaded archives. - ---- - -### Include a single file from another repository - -Including single files uses the same mechanism as including a subdirectory -above, except with the external type `file` instead of `archive`. For example, -to include -[`plug.vim`](https://github.com/junegunn/vim-plug/blob/master/plug.vim) from -[`github.com/junegunn/vim-plug`](https://github.com/junegunn/vim-plug) in -`~/.vim/autoload/plug.vim` put the following in -`~/.local/share/chezmoi/.chezmoiexternals.toml`: - -```toml -[".vim/autoload/plug.vim"] - type = "file" - url = "https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim" - refreshPeriod = "168h" -``` - ---- - -### Handle configuration files which are externally modified - -Some programs modify their configuration files. When you next run `chezmoi -apply`, any modifications made by the program will be lost. - -You can track changes to these files by replacing with a symlink back to a file -in your source directory, which is under version control. Here is a worked -example for VSCode's `settings.json` on Linux: - -Copy the configuration file to your source directory: - -```console -$ cp ~/.config/Code/User/settings.json $(chezmoi source-path) -``` - -Tell chezmoi to ignore this file: - -```console -$ echo settings.json >> $(chezmoi source-path)/.chezmoiignore -``` - -Tell chezmoi that `~/.config/Code/User/settings.json` should be a symlink to the -file in your source directory: - -```console -$ mkdir -p $(chezmoi source-path)/private_dot_config/private_Code/User -$ echo -n "{{ .chezmoi.sourceDir }}/settings.json" > $(chezmoi source-path)/private_dot_config/private_Code/User/symlink_settings.json.tmpl -``` - -The prefix `private_` is used because the `~/.config` and `~/.config/Code` -directories are private by default. - -Apply the changes: - -```console -$ chezmoi apply -v -``` - -Now, when the program modifies its configuration file it will modify the file in -the source state instead. - ---- - -### Import archives - -It is occasionally useful to import entire archives of configuration into your -source state. The `import` command does this. For example, to import the latest -version [`github.com/ohmyzsh/ohmyzsh`](https://github.com/ohmyzsh/ohmyzsh) to -`~/.oh-my-zsh` run: - -```console -$ curl -s -L -o ${TMPDIR}/oh-my-zsh-master.tar.gz https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz -$ mkdir -p $(chezmoi source-path)/dot_oh-my-zsh -$ chezmoi import --strip-components 1 --destination ~/.oh-my-zsh ${TMPDIR}/oh-my-zsh-master.tar.gz -``` - -Note that this only updates the source state. You will need to run - -```console -$ chezmoi apply -``` - -to update your destination directory. - ---- - -## Manage machine-to-machine differences - ---- - -### Use templates - -The primary goal of chezmoi is to manage configuration files across multiple -machines, for example your personal macOS laptop, your work Ubuntu desktop, and -your work Linux laptop. You will want to keep much configuration the same across -these, but also need machine-specific configurations for email addresses, -credentials, etc. chezmoi achieves this functionality by using -[`text/template`](https://pkg.go.dev/text/template) for the source state where -needed. - -For example, your home `~/.gitconfig` on your personal machine might look like: - -```toml -[user] - email = "me@home.org" -``` - -Whereas at work it might be: - -```toml -[user] - email = "firstname.lastname@company.com" -``` - -To handle this, on each machine create a configuration file called -`~/.config/chezmoi/chezmoi.toml` defining variables that might vary from machine -to machine. For example, for your home machine: - -```toml -[data] - email = "me@home.org" -``` - -Note that all variable names will be converted to lowercase. This is due to a -feature of a library used by chezmoi. - -If you intend to store private data (e.g. access tokens) in -`~/.config/chezmoi/chezmoi.toml`, make sure it has permissions `0600`. - -If you prefer, you can use any format supported by -[Viper](https://github.com/spf13/viper) for your configuration file. This -includes JSON, YAML, and TOML. Variable names must start with a letter and be -followed by zero or more letters or digits. - -Then, add `~/.gitconfig` to chezmoi using the `--autotemplate` flag to turn it -into a template and automatically detect variables from the `data` section -of your `~/.config/chezmoi/chezmoi.toml` file: - -```console -$ chezmoi add --autotemplate ~/.gitconfig -``` - -You can then open the template (which will be saved in the file -`~/.local/share/chezmoi/dot_gitconfig.tmpl`): - -```console -$ chezmoi edit ~/.gitconfig -``` - -The file should look something like: - -```toml -[user] - email = {{ .email | quote }} -``` - -To disable automatic variable detection, use the `--template` or `-T` option to -`chezmoi add` instead of `--autotemplate`. - -Templates are often used to capture machine-specific differences. For example, -in your `~/.local/share/chezmoi/dot_bashrc.tmpl` you might have: - -``` -# common config -export EDITOR=vi - -# machine-specific configuration -{{- if eq .chezmoi.hostname "work-laptop" }} -# this will only be included in ~/.bashrc on work-laptop -{{- end }} -``` - -For a full list of variables, run: - -```console -$ chezmoi data -``` - -For more advanced usage, you can use the full power of the -[`text/template`](https://pkg.go.dev/text/template) language. chezmoi includes -all of the text functions from [sprig](http://masterminds.github.io/sprig/) and -its own [functions for interacting with password -managers](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#template-functions). - -Templates can be executed directly from the command line, without the need to -create a file on disk, with the `execute-template` command, for example: - -```console -$ chezmoi execute-template "{{ .chezmoi.os }}/{{ .chezmoi.arch }}" -``` - -This is useful when developing or debugging templates. - -Some password managers allow you to store complete files. The files can be -retrieved with chezmoi's template functions. For example, if you have a file -stored in 1Password with the UUID `uuid` then you can retrieve it with the -template: - -``` -{{- onepasswordDocument "uuid" -}} -``` - -The `-`s inside the brackets remove any whitespace before or after the template -expression, which is useful if your editor has added any newlines. - -If, after executing the template, the file contents are empty, the target file -will be removed. This can be used to ensure that files are only present on -certain machines. If you want an empty file to be created anyway, you will need -to give it an `empty_` prefix. - ---- - -### Ignore files or a directory on different machines - -For coarser-grained control of files and entire directories managed on different -machines, or to exclude certain files completely, you can create -`.chezmoiignore` files in the source directory. These specify a list of patterns -that chezmoi should ignore, and are interpreted as templates. An example -`.chezmoiignore` file might look like: - -``` -README.md -{{- if ne .chezmoi.hostname "work-laptop" }} -.work # only manage .work on work-laptop -{{- end }} -``` - -The use of `ne` (not equal) is deliberate. What we want to achieve is "only -install `.work` if hostname is `work-laptop`" but chezmoi installs everything by -default, so we have to turn the logic around and instead write "ignore `.work` -unless the hostname is `work-laptop`". - -Patterns can be excluded by prefixing them with a `!`, for example: - -``` -f* -!foo -``` - -will ignore all files beginning with an `f` except `foo`. - ---- - -### Use completely different dotfiles on different machines - -chezmoi's template functionality allows you to change a file's contents based on -any variable. For example, if you want `~/.bashrc` to be different on Linux and -macOS you would create a file in the source state called `dot_bashrc.tmpl` -containing: - -``` -{{ if eq .chezmoi.os "darwin" -}} -# macOS .bashrc contents -{{ else if eq .chezmoi.os "linux" -}} -# Linux .bashrc contents -{{ end -}} -``` - -However, if the differences between the two versions are so large that you'd -prefer to use completely separate files in the source state, you can achieve -this using a symbolic link template. Create the following files: - -`symlink_dot_bashrc.tmpl`: - -``` -.bashrc_{{ .chezmoi.os }} -``` - -`dot_bashrc_darwin`: - -``` - # macOS .bashrc contents -``` - -`dot_bashrc_linux`: - -``` -# Linux .bashrc contents -``` - -`.chezmoiignore` - -``` -{{ if ne .chezmoi.os "darwin" }} -.bashrc_darwin -{{ end }} -{{ if ne .chezmoi.os "linux" }} -.bashrc_linux -{{ end }} -``` - -This will make `~/.bashrc` a symlink to `.bashrc_darwin` on `darwin` and to -`.bashrc_linux` on `linux`. The `.chezmoiignore` configuration ensures that only -the OS-specific `.bashrc_os` file will be installed on each OS. - -#### Without using symlinks - -The same thing can be achieved using the include function. - -`dot_bashrc.tmpl` - -``` -{{ if eq .chezmoi.os "darwin" }} -{{ include ".bashrc_darwin" }} -{{ end }} -{{ if eq .chezmoi.os "linux" }} -{{ include ".bashrc_linux" }} -{{ end }} -``` - ---- - -### Create a config file on a new machine automatically - -`chezmoi init` can also create a config file automatically, if one does not -already exist. If your repo contains a file called `.chezmoi..tmpl` -where *format* is one of the supported config file formats (e.g. `json`, `toml`, -or `yaml`) then `chezmoi init` will execute that template to generate your -initial config file. - -Specifically, if you have `.chezmoi.toml.tmpl` that looks like this: - -``` -{{- $email := promptString "email" -}} -[data] - email = {{ $email | quote }} -``` - -Then `chezmoi init` will create an initial `chezmoi.toml` using this template. -`promptString` is a special function that prompts the user (you) for a value. - -To test this template, use `chezmoi execute-template` with the `--init` and -`--promptString` flags, for example: - -```console -$ chezmoi execute-template --init --promptString email=me@home.org < ~/.local/share/chezmoi/.chezmoi.toml.tmpl -``` - ---- - -### Re-create your config file - -If you change your config file template, chezmoi will warn you if your current -config file was not generated from that template. You can re-generate your -config file by running: - -```console -$ chezmoi init -``` - -If you are using any `prompt*` template functions in your config file template -you will be prompted again. However, you can avoid this with the following -example template logic: - -``` -{{- $email := "" -}} -{{- if (hasKey . "email") -}} -{{- $email = .email -}} -{{- else -}} -{{- $email = promptString "email" -}} -{{- end -}} - -[data] - email = {{ $email | quote }} -``` - -This will cause chezmoi to first try to re-use the existing `$email` variable -and fallback to `promptString` only if it is not set. - ---- - -### Handle different file locations on different systems with the same contents - -If you want to have the same file contents in different locations on different -systems, but maintain only a single file in your source state, you can use -a shared template. - -Create the common file in the `.chezmoitemplates` directory in the source state. For -example, create `.chezmoitemplates/file.conf`. The contents of this file are -available in templates with the `template *name* .` function where *name* is the -name of the file (`.` passes the current data to the template code in `file.conf`; -see https://pkg.go.dev/text/template#hdr-Actions for details). - -Then create files for each system, for example `Library/Application -Support/App/file.conf.tmpl` for macOS and `dot_config/app/file.conf.tmpl` for -Linux. Both template files should contain `{{- template "file.conf" . -}}`. - -Finally, tell chezmoi to ignore files where they are not needed by adding lines -to your `.chezmoiignore` file, for example: - -``` -{{ if ne .chezmoi.os "darwin" }} -Library/Application Support/App/file.conf -{{ end }} -{{ if ne .chezmoi.os "linux" }} -.config/app/file.conf -{{ end }} -``` - ---- - -### Create an archive of your dotfiles - -`chezmoi archive` creates an archive containing the target state. This can be -useful for generating target state for a different machine. You can specify a -different configuration file (including template variables) with the `--config` -option. - ---- - -## Keep data private - -chezmoi automatically detects when files and directories are private when adding -them by inspecting their permissions. Private files and directories are stored -in `~/.local/share/chezmoi` as regular, public files with permissions `0644` and -the name prefix `private_`. For example: - -``` -$ chezmoi add ~/.netrc -``` - -will create `~/.local/share/chezmoi/private_dot_netrc` (assuming `~/.netrc` is -not world- or group- readable, as it should be). This file is still private -because `~/.local/share/chezmoi` is not group- or world- readable or executable. -chezmoi checks that the permissions of `~/.local/share/chezmoi` are `0700` on -every run and will print a warning if they are not. - -It is common that you need to store access tokens in config files, e.g. a -[GitHub access -token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/). -There are several ways to keep these tokens secure, and to prevent them leaving -your machine. - ---- - -### Use 1Password - -chezmoi includes support for [1Password](https://1password.com/) using the -[1Password CLI](https://support.1password.com/command-line-getting-started/) to -expose data as a template function. - -Log in and get a session using: - -```console -$ eval $(op signin .1password.com ) -``` - -The output of `op get item ` is available as the `onepassword` template -function. chezmoi parses the JSON output and returns it as structured data. For -example, if the output of `op get item ""` is: - -```json -{ - "uuid": "", - "details": { - "password": "xxx" - } -} -``` - -Then you can access `details.password` with the syntax: - -``` -{{ (onepassword "").details.password }} -``` - -Login details fields can be retrieved with the `onepasswordDetailsFields` -function, for example: - -``` -{{- (onepasswordDetailsFields "uuid").password.value }} -``` - -Documents can be retrieved with: - -``` -{{- onepasswordDocument "uuid" -}} -``` - -Note the extra `-` after the opening `{{` and before the closing `}}`. This -instructs the template language to remove any whitespace before and after the -substitution. This removes any trailing newline added by your editor when saving -the template. - ---- - -### Use Bitwarden - -chezmoi includes support for [Bitwarden](https://bitwarden.com/) using the -[Bitwarden CLI](https://github.com/bitwarden/cli) to expose data as a template -function. - -Log in to Bitwarden using: - -```console -$ bw login -``` - -Unlock your Bitwarden vault: - -```console -$ bw unlock -``` - -Set the `BW_SESSION` environment variable, as instructed. - -The structured data from `bw get` is available as the `bitwarden` template -function in your config files, for example: - -``` -username = {{ (bitwarden "item" "example.com").login.username }} -password = {{ (bitwarden "item" "example.com").login.password }} -``` - -Custom fields can be accessed with the `bitwardenFields` template function. For -example, if you have a custom field named `token` you can retrieve its value -with: - -``` -{{ (bitwardenFields "item" "example.com").token.value }} -``` - ---- - -### Use gopass - -chezmoi includes support for [gopass](https://www.gopass.pw/) using the gopass CLI. - -The first line of the output of `gopass show ` is available as the -`gopass` template function, for example: - -``` -{{ gopass "" }} -``` - ---- - -### Use KeePassXC - -chezmoi includes support for [KeePassXC](https://keepassxc.org) using the -KeePassXC CLI (`keepassxc-cli`) to expose data as a template function. - -Provide the path to your KeePassXC database in your configuration file: - -```toml -[keepassxc] - database = "/home/user/Passwords.kdbx" -``` - -The structured data from `keepassxc-cli show $database` is available as the -`keepassxc` template function in your config files, for example: - -``` -username = {{ (keepassxc "example.com").UserName }} -password = {{ (keepassxc "example.com").Password }} -``` - -Additional attributes are available through the `keepassxcAttribute` function. -For example, if you have an entry called `SSH Key` with an additional attribute -called `private-key`, its value is available as: - -``` -{{ keepassxcAttribute "SSH Key" "private-key" }} -``` - ---- - -### Use Keychain or Windows Credentials Manager - -chezmoi includes support for Keychain (on macOS), GNOME Keyring (on Linux), and -Windows Credentials Manager (on Windows) via the -[`zalando/go-keyring`](https://github.com/zalando/go-keyring) library. - -Set values with: - -```console -$ chezmoi secret keyring set --service= --user= -Value: xxxxxxxx -``` - -The value can then be used in templates using the `keyring` function which takes -the service and user as arguments. - -For example, save a GitHub access token in keyring with: - -```console -$ chezmoi secret keyring set --service=github --user= -Value: xxxxxxxx -``` - -and then include it in your `~/.gitconfig` file with: - -``` -[github] - user = {{ .github.user | quote }} - token = {{ keyring "github" .github.user | quote }} -``` - -You can query the keyring from the command line: - -```console -$ chezmoi secret keyring get --service=github --user= -``` - ---- - -### Use LastPass - -chezmoi includes support for [LastPass](https://lastpass.com) using the -[LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html) to expose -data as a template function. - -Log in to LastPass using: - -```console -$ lpass login -``` - -Check that `lpass` is working correctly by showing password data: - -``` console -$ lpass show --json -``` - -where `` is a [LastPass Entry -Specification](https://lastpass.github.io/lastpass-cli/lpass.1.html#_entry_specification). - -The structured data from `lpass show --json id` is available as the `lastpass` -template function. The value will be an array of objects. You can use the -`index` function and `.Field` syntax of the `text/template` language to extract -the field you want. For example, to extract the `password` field from first the -"GitHub" entry, use: - -``` -githubPassword = {{ (index (lastpass "GitHub") 0).password | quote }} -``` - -chezmoi automatically parses the `note` value of the Lastpass entry as -colon-separated key-value pairs, so, for example, you can extract a private SSH -key like this: - -``` -{{ (index (lastpass "SSH") 0).note.privateKey }} -``` - -Keys in the `note` section written as `CamelCase Words` are converted to -`camelCaseWords`. - -If the `note` value does not contain colon-separated key-value pairs, then you -can use `lastpassRaw` to get its raw value, for example: - -``` -{{ (index (lastpassRaw "SSH Private Key") 0).note }} -``` - ---- - -### Use pass - -chezmoi includes support for [pass](https://www.passwordstore.org/) using the -pass CLI. - -The first line of the output of `pass show ` is available as the -`pass` template function, for example: - -``` -{{ pass "" }} -``` - ---- - -### Use Vault - -chezmoi includes support for [Vault](https://www.vaultproject.io/) using the -[Vault CLI](https://www.vaultproject.io/docs/commands/) to expose data as a -template function. - -The vault CLI needs to be correctly configured on your machine, e.g. the -`VAULT_ADDR` and `VAULT_TOKEN` environment variables must be set correctly. -Verify that this is the case by running: - -```console -$ vault kv get -format=json -``` - -The structured data from `vault kv get -format=json` is available as the `vault` -template function. You can use the `.Field` syntax of the `text/template` -language to extract the data you want. For example: - -``` -{{ (vault "").data.data.password }} -``` - ---- - -### Use a custom password manager - -You can use any command line tool that outputs secrets either as a string or in -JSON format. Choose the binary by setting `secret.command` in your configuration -file. You can then invoke this command with the `secret` and `secretJSON` -template functions which return the raw output and JSON-decoded output -respectively. All of the above secret managers can be supported in this way: - -| Secret Manager | `secret.command` | Template skeleton | -| --------------- | ---------------- | ------------------------------------------------- | -| 1Password | `op` | `{{ secretJSON "get" "item" }}` | -| Bitwarden | `bw` | `{{ secretJSON "get" }}` | -| HashiCorp Vault | `vault` | `{{ secretJSON "kv" "get" "-format=json" }}` | -| LastPass | `lpass` | `{{ secretJSON "show" "--json" }}` | -| KeePassXC | `keepassxc-cli` | Not possible (interactive command only) | -| pass | `pass` | `{{ secret "show" }}` | - ---- - -### Encrypt whole files with gpg - -chezmoi supports encrypting files with [gpg](https://www.gnupg.org/). Encrypted -files are stored in the source state and automatically be decrypted when -generating the target state or printing a file's contents with `chezmoi cat`. -`chezmoi edit` will transparently decrypt the file before editing and re-encrypt -it afterwards. - ---- - -#### Asymmetric (private/public-key) encryption - -Specify the encryption key to use in your configuration file (`chezmoi.toml`) -with the `gpg.recipient` key: - -```toml -encryption = "gpg" -[gpg] - recipient = "..." -``` - -Add files to be encrypted with the `--encrypt` flag, for example: - -```console -$ chezmoi add --encrypt ~/.ssh/id_rsa -``` - -chezmoi will encrypt the file with: - -```bash -gpg --armor --recipient ${gpg.recipient} --encrypt -``` - -and store the encrypted file in the source state. The file will automatically be -decrypted when generating the target state. - ---- - -#### Symmetric encryption - -Specify symmetric encryption in your configuration file: - -```toml -encryption = "gpg" -[gpg] - symmetric = true -``` - -Add files to be encrypted with the `--encrypt` flag, for example: - -```console -$ chezmoi add --encrypt ~/.ssh/id_rsa -``` - -chezmoi will encrypt the file with: - -```bash -gpg --armor --symmetric -``` - ---- - -### Encrypt whole files with age - -chezmoi supports encrypting files with [age](https://age-encryption.org/). -Encrypted files are stored in the source state and automatically be decrypted -when generating the target state or printing a file's contents with `chezmoi -cat`. `chezmoi edit` will transparently decrypt the file before editing and -re-encrypt it afterwards. - -Generate a key using `age-keygen`: - -```console -$ age-keygen -o $HOME/key.txt -Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p -``` - -Specify age encryption in your configuration file, being sure to specify at -least the identity and one recipient: - -```toml -encryption = "age" -[age] - identity = "/home/user/key.txt" - recipient = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p" -``` - -Add files to be encrypted with the `--encrypt` flag, for example: - -```console -$ chezmoi add --encrypt ~/.ssh/id_rsa -``` - -chezmoi supports multiple recipients and recipient files, and multiple -identities. - ---- - -#### Symmetric encryption - -To use age's symmetric encryption, specify a single identity and enable -symmetric encryption in your config file, for example: - -```toml -encryption = "age" -[age] - identity = "~/.ssh/id_rsa" - symmetric = true -``` - ---- - -#### Symmetric encryption with a passphrase - -To use age's symmetric encryption with a passphrase, set `age.passphrase` to -`true` in your config file, for example: - -```toml -encryption = "age" -[age] - passphrase = true -``` - -You will be prompted for the passphrase whenever you run `chezmoi add --encrypt` -and whenever chezmoi needs to decrypt the file, for example when you run -`chezmoi apply`, `chezmoi diff`, or `chezmoi status`. - ---- - -### Use a private configuration file and template variables - -Typically, `~/.config/chezmoi/chezmoi.toml` is not checked in to version control -and has permissions 0600. You can store tokens as template values in the `data` -section. For example, if your `~/.config/chezmoi/chezmoi.toml` contains: - -```toml -[data.github] - user = "" - token = "" -``` - -Your `~/.local/share/chezmoi/private_dot_gitconfig.tmpl` can then contain: - -``` -{{- if (index . "github") }} -[github] - user = {{ .github.user | quote }} - token = {{ .github.token | quote }} -{{- end }} -``` - - -Any config files containing tokens in plain text should be private (permissions -`0600`). - ---- - -## Use scripts to perform actions - ---- - -### Understand how scripts work - -chezmoi supports scripts, which are executed when you run `chezmoi apply`. The -scripts can either run every time you run `chezmoi apply`, or only when their -contents have changed. - -In verbose mode, the script's contents will be printed before executing it. In -dry-run mode, the script is not executed. - -Scripts are any file in the source directory with the prefix `run_`, and are -executed in alphabetical order. Scripts that should only be run if they have not -been run before have the prefix `run_once_`. Scripts that should be run whenever -their contents change have the `run_onchange_` prefix. - -Scripts break chezmoi's declarative approach, and as such should be used -sparingly. Any script should be idempotent, even `run_once_` and -`run_onchange_` scripts. - -Scripts must be created manually in the source directory, typically by running -`chezmoi cd` and then creating a file with a `run_` prefix. Scripts are executed -directly using `exec` and must include a shebang line or be executable binaries. -There is no need to set the executable bit on the script. - -Scripts with the suffix `.tmpl` are treated as templates, with the usual -template variables available. If, after executing the template, the result is -only whitespace or an empty string, then the script is not executed. This is -useful for disabling scripts. - ---- - -### Install packages with scripts - -Change to the source directory and create a file called -`run_once_install-packages.sh`: - -```console -$ chezmoi cd -$ $EDITOR run_once_install-packages.sh -``` - -In this file create your package installation script, e.g. - -```sh -#!/bin/sh -sudo apt install ripgrep -``` - -The next time you run `chezmoi apply` or `chezmoi update` this script will be -run. As it has the `run_once_` prefix, it will not be run again unless its -contents change, for example if you add more packages to be installed. - -This script can also be a template. For example, if you create -`run_once_install-packages.sh.tmpl` with the contents: - -``` -{{ if eq .chezmoi.os "linux" -}} -#!/bin/sh -sudo apt install ripgrep -{{ else if eq .chezmoi.os "darwin" -}} -#!/bin/sh -brew install ripgrep -{{ end -}} -``` - -This will install `ripgrep` on both Debian/Ubuntu Linux systems and macOS. - ---- - -### Run a script when the contents of another file changes - -chezmoi's `run_` scripts are run every time you run `chezmoi apply`, whereas -`run_once_` scripts are run only when their contents have changed, after -executing them as templates. You use this to cause a `run_once_` script to run -when the contents of another file has changed by including a checksum of the -other file's contents in the script. - -For example, if your [dconf](https://wiki.gnome.org/Projects/dconf) settings are -stored in `dconf.ini` in your source directory then you can make `chezmoi apply` -only load them when the contents of `dconf.ini` has changed by adding the -following script as `run_once_dconf-load.sh.tmpl`: - -``` -#!/bin/bash - -# dconf.ini hash: {{ include "dconf.ini" | sha256sum }} -dconf load / {{ joinPath .chezmoi.sourceDir "dconf.ini" | quote }} -``` - -As the SHA256 sum of `dconf.ini` is included in a comment in the script, the -contents of the script will change whenever the contents of `dconf.ini` are -changed, so chezmoi will re-run the script whenever the contents of `dconf.ini` -change. - -In this example you should also add `dconf.ini` to `.chezmoiignore` so chezmoi -does not create `dconf.ini` in your home directory. - ---- - -## Use chezmoi on macOS - ---- - -### Use `brew bundle` to manage your brews and casks - -Homebrew's [`brew bundle` -subcommand](https://docs.brew.sh/Manpage#bundle-subcommand) allows you to -specify a list of brews and casks to be installed. You can integrate this with -chezmoi by creating a `run_once_` script. For example, create a file in your -source directory called `run_once_before_install-packages-darwin.sh.tmpl` -containing: - -``` -{{- if (eq .chezmoi.os "darwin") -}} -#!/bin/bash - -brew bundle --no-lock --file=/dev/stdin <&2 - exit 1 - fi -else - chezmoi=chezmoi -fi - -# POSIX way to get script's dir: https://stackoverflow.com/a/29834779/12156188 -script_dir="$(cd -P -- "$(dirname -- "$(command -v -- "$0")")" && pwd -P)" -# exec: replace current process with chezmoi init -exec "$chezmoi" init --apply "--source=$script_dir" -``` - -Ensure that this file is executable (`chmod a+x install.sh`), and add -`install.sh` to your `.chezmoiignore` file. - -It installs the latest version of chezmoi in `~/.local/bin` if needed, and then -`chezmoi init ...` invokes chezmoi to create its configuration file and -initialize your dotfiles. `--apply` tells chezmoi to apply the changes -immediately, and `--source=...` tells chezmoi where to find the cloned -`dotfiles` repo, which in this case is the same folder in which the script is -running from. - -If you do not use a chezmoi configuration file template you can use `chezmoi -apply --source=$HOME/dotfiles` instead of `chezmoi init ...` in `install.sh`. - -Finally, modify any of your templates to use the `codespaces` variable if -needed. For example, to install `vim-gtk` on Linux but not in Codespaces, your -`run_once_install-packages.sh.tmpl` might contain: - -``` -{{- if (and (eq .chezmoi.os "linux") (not .codespaces)) -}} -#!/bin/sh -sudo apt install -y vim-gtk -{{- end -}} -``` - ---- - -## Customize chezmoi - ---- - -### Use a subdirectory of your dotfiles repo as the root of the source state - -By default, chezmoi uses the root of your dotfiles repo as the root of the -source state. If your source state contains many entries in its root, then your -target directory (usually your home directory) will in turn be filled with many -entries in its root as well. You can reduce the number of entries by keeping -`.chezmoiignore` up to date, but this can become tiresome. - -Instead, you can specify that chezmoi should read the source state from a -subdirectory of the source directory instead by creating a file called -`.chezmoiroot` containing the relative path to this subdirectory. - -For example, if `.chezmoiroot` contains: - -``` -home -``` - -Then chezmoi will read the source state from the `home` subdirectory of your -source directory, for example the desired state of `~/.gitconfig` will be read -from `~/.local/share/chezmoi/home/dot_gitconfig` (instead of -`~/.local/share/chezmoi/dot_gitconfig`). - -When migrating an existing chezmoi dotfiles repo to use `.chezmoiroot` you will -need to move the relevant files in to the new root subdirectory manually. You do -not need to move files that are ignored by chezmoi in all cases (i.e. are listed -in `.chezmoiignore` when executed as a template on all machines), and you can -afterwards remove their entries from `home/.chezmoiignore`. - ---- - -### Don't show scripts in the diff output - -By default, `chezmoi diff` will show all changes, including the contents of -scripts that will be run. You can exclude scripts from the diff output by -setting the `diff.exclude` configuration variable in your configuration file, -for example: - -```toml -[diff] - exclude = ["scripts"] -``` - ---- - -### Customize the diff pager - -You can change the diff format, and/or pipe the output into a pager of your -choice by setting `diff.pager` configuration variable. For example, to use -[`diff-so-fancy`](https://github.com/so-fancy/diff-so-fancy) specify: - -```toml -[diff] - pager = "diff-so-fancy" -``` - -The pager can be disabled using the `--no-pager` flag or by setting `diff.pager` -to an empty string. - ---- - -### Use a custom diff tool - -By default, chezmoi uses a built-in diff. You can use a custom tool by setting -the `diff.command` and `diff.args` configuration variables. The elements of -`diff.args` are interpreted as templates with the variables `.Destination` and -`.Target` containing filenames of the file in the destination state and the -target state respectively. For example, to use [meld](https://meldmerge.org/), -specify: - -```toml -[diff] - command = "meld" - args = ["--diff", "{{ .Destination }}", "{{ .Target }}"] -``` - ---- - -### Use a custom merge tool - -By default, chezmoi uses vimdiff. You can use a custom tool by setting the -`merge.command` and `merge.args` configuration variables. The elements of -`merge.args` are interprested as templates with the variables `.Destination`, -`.Source`, and `.Target` containing filenames of the file in the destination -state, source state, and target state respectively. For example, to use -[neovim's diff mode](https://neovim.io/doc/user/diff.html), specify: - -```toml -[merge] - command = "nvim" - args = ["-d", "{{ .Destination }}", "{{ .Source }}", "{{ .Target }}"] -``` - ---- - -### Use an HTTP or SOCKS5 proxy - -chezmoi supports HTTP, HTTPS, and SOCKS5 proxies. Set the `HTTP_PROXY`, -`HTTPS_PROXY`, and `NO_PROXY` environment variables, or their lowercase -equivalents, for example: - -```console -$ HTTP_PROXY=socks5://127.0.0.1:1080 chezmoi apply -R -``` - ---- - -## Migrating to chezmoi from another dotfile manager - ---- - -### Migrate from a dotfile manager that uses symlinks - -Many dotfile managers replace dotfiles with symbolic links to files in a common -directory. If you `chezmoi add` such a symlink, chezmoi will add the symlink, -not the file. To assist with migrating from symlink-based systems, use the -`--follow` option to `chezmoi add`, for example: - -```console -$ chezmoi add --follow ~/.bashrc -``` - -This will tell `chezmoi add` that the target state of `~/.bashrc` is the target -of the `~/.bashrc` symlink, rather than the symlink itself. When you run -`chezmoi apply`, chezmoi will replace the `~/.bashrc` symlink with the file -contents. - ---- - -## Migrate away from chezmoi - -chezmoi provides several mechanisms to help you move to an alternative dotfile -manager (or even no dotfile manager at all) in the future: - -* chezmoi creates your dotfiles just as if you were not using a dotfile manager - at all. Your dotfiles are regular files, directories, and symlinks. You can - run [`chezmoi - purge`](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#purge) - to delete all traces of chezmoi and then, if you're migrating to a new dotfile - manager, then you can use whatever mechanism it provides to add your dotfiles - to your new system. -* chezmoi has a [`chezmoi - archive`](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#archive) - command that generates a tarball of your dotfiles. You can replace the - contents of your dotfiles repo with the contents of the archive and you've - effectively immediately migrated away from chezmoi. -* chezmoi has a [`chezmoi - dump`](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#dump-target) - command that dumps the interpreted (target) state in a machine-readable form, - so you can write scripts around chezmoi. diff --git a/docs/INSTALL.md b/docs/INSTALL.md deleted file mode 100644 index 5193220c86f..00000000000 --- a/docs/INSTALL.md +++ /dev/null @@ -1,124 +0,0 @@ -# chezmoi install guide - - -* [One-line binary install](#one-line-binary-install) -* [One-line package install](#one-line-package-install) -* [Pre-built Linux packages](#pre-built-linux-packages) -* [Pre-built binaries](#pre-built-binaries) -* [All pre-built Linux packages and binaries](#all-pre-built-linux-packages-and-binaries) -* [From source](#from-source) - ---- - -## One-line binary install - -Install the correct binary for your operating system and architecture in `./bin` -with a single command: - -```console -$ sh -c "$(curl -fsLS git.io/chezmoi)" -``` - -Or, if you have `wget` instead of `curl`: - -```console -$ sh -c "$(wget -qO- git.io/chezmoi)" -``` - -If you already have a dotfiles repo using chezmoi on GitHub at -`https://github.com//dotfiles` then you can install chezmoi and -your dotfiles with the single command: - -```console -$ sh -c "$(curl -fsLS git.io/chezmoi)" -- init --apply -``` - -Or on systems with Powershell, you can use one of the following command: - -``` -# To install in ./bin -(iwr -UseBasicParsing https://git.io/chezmoi.ps1).Content | powershell -c - - -# To install in another location -'$params = "-BinDir ~/other"', (iwr https://git.io/chezmoi.ps1).Content | powershell -c - - -# For information about other options, run -'$params = "-?"', (iwr https://git.io/chezmoi.ps1).Content | powershell -c - -``` - ---- - -## One-line package install - -Install chezmoi with a single command. - -| OS | Method | Command | -| ------------ | ---------- | ------------------------------------------------------------------------------------------- | -| Linux | snap | `snap install chezmoi --classic` | -| Linux | Linuxbrew | `brew install chezmoi` | -| Alpine Linux | apk | `apk add chezmoi` | -| Arch Linux | pacman | `pacman -S chezmoi` | -| Guix Linux | guix | `guix install chezmoi` | -| OpenIndiana | pkg | `pkg install application/chezmoi` | -| NixOS Linux | nix-env | `nix-env -i chezmoi` | -| Void Linux | xbps | `xbps-install -S chezmoi` | -| macOS | Homebrew | `brew install chezmoi` | -| macOS | MacPorts | `port install chezmoi` | -| Windows | Scoop | `scoop bucket add twpayne https://github.com/twpayne/scoop-bucket && scoop install chezmoi` | -| Windows | Chocolatey | `choco install chezmoi` | -| FreeBSD | pkg | `pkg install chezmoi` | - ---- - -## Pre-built Linux packages - -Download a package for your operating system and architecture and install it -with your package manager. - -| Distribution | Architectures | Package | -| ------------ | --------------------------------------------------------- | ----------------------------------------------------------- | -| Alpine | `386`, `amd64`, `arm64`, `arm`, `ppc64`, `ppc64le` | [`apk`](https://github.com/twpayne/chezmoi/releases/latest) | -| Debian | `amd64`, `arm64`, `armel`, `i386`, `ppc64`, `ppc64le` | [`deb`](https://github.com/twpayne/chezmoi/releases/latest) | -| RedHat | `aarch64`, `armhfp`, `i686`, `ppc64`, `ppc64le`, `x86_64` | [`rpm`](https://github.com/twpayne/chezmoi/releases/latest) | -| OpenSUSE | `aarch64`, `armhfp`, `i686`, `ppc64`, `ppc64le`, `x86_64` | [`rpm`](https://github.com/twpayne/chezmoi/releases/latest) | -| Ubuntu | `amd64`, `arm64`, `armel`, `i386`, `ppc64`, `ppc64le` | [`deb`](https://github.com/twpayne/chezmoi/releases/latest) | - ---- - -## Pre-built binaries - -Download an archive for your operating system containing a pre-built binary, -documentation, and shell completions. - -| OS | Architectures | Archive | -| ---------- | --------------------------------------------------- | -------------------------------------------------------------- | -| FreeBSD | `amd64`, `arm`, `arm64`, `i386` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) | -| Illumos | `amd64` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) | -| Linux | `amd64`, `arm`, `arm64`, `i386`, `ppc64`, `ppc64le` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) | -| macOS | `amd64`, `arm64` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) | -| OpenBSD | `amd64`, `arm`, `arm64`, `i386` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) | -| Solaris | `amd64` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) | -| Windows | `amd64`, `arm`, `i386` | [`zip`](https://github.com/twpayne/chezmoi/releases/latest) | - ---- - -## All pre-built Linux packages and binaries - -All pre-built binaries and packages can be found on the [chezmoi GitHub releases -page](https://github.com/twpayne/chezmoi/releases/latest). - ---- - -## From source - -Download, build, and install chezmoi for your system: - -```console -$ git clone https://github.com/twpayne/chezmoi.git -$ cd chezmoi -$ make install -``` - -Building chezmoi requires Go 1.16 or later. - ---- diff --git a/docs/MEDIA.md b/docs/MEDIA.md deleted file mode 100644 index 39af74c46e1..00000000000 --- a/docs/MEDIA.md +++ /dev/null @@ -1,68 +0,0 @@ -# chezmoi in the media - - - -Recommended article: [Fedora Magazine: Take back your dotfiles with Chezmoi](https://fedoramagazine.org/take-back-your-dotfiles-with-chezmoi/) - -Recommended video: [chezmoi: manage your dotfiles across multiple, diverse machines, securely](https://fosdem.org/2021/schedule/event/chezmoi/) - -Recommended podcast: [Managing Dot Files and an Introduction to Chezmoi](https://www.podfeet.com/blog/2021/07/ccatp-693/) - ---- - -| Date | Version | Format | Link | -| ---------- | ------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2021-11-27 | 2.8.0 | Video (TH) | [Command ไร 2021-11-27 : ย้าย dotfiles ไป chezmoi](https://www.youtube.com/watch?v=8ybNfCfnF2Y) | -| 2021-11-26 | 2.8.0 | Text | [Weekly Journal 47 - chezmoi, neovim](https://scottbanwart.com/blog/2021/11/weekly-journal-47-chezmoi-neovim/) | -| 2021-11-23 | 2.8.0 | Text | [chezmoi dotfile management](https://www.jacobbolda.com/chezmoi-dotfile-management) | -| 2021-10-26 | 2.7.3 | Text (RU) | [Синхронизация системных настроек](https://habr.com/en/post/585578/) | -| 2021-09-18 | 2.1.2 | Audio/text | [PBS 125 of X — Chezmoi on Multiple Computers](https://pbs.bartificer.net/pbs125) | -| 2021-09-14 | 2.2.0 | Text | [Managing preference plists under Chezmoi](https://zacwe.st/2021/09/14/managing-preference-plists-under-chezmoi/) | -| 2021-09-06 | 2.2.0 | Video | [chezmoi: Organize your dotfiles across multiple computers \| Let's Code](https://www.youtube.com/watch?v=L_Y3s0PS_Cg) | -| 2021-09-06 | 2.2.0 | Text | [chezmoi dotfile management](https://www.jacobbolda.com/chezmoi-dotfile-management) | -| 2021-09-04 | 2.2.0 | Text | [Configuration Management](https://cj.rs/blog/my-setup/chezmoi/) | -| 2021-09-04 | 2.1.2 | Audio/text | [PBS 124 of X — Chezmoi Templates](https://pbs.bartificer.net/pbs124) | -| 2021-08-22 | 2.1.2 | Audio/text | [PBS 123 of X — Backing up and Syncing Dot Files with Chezmoi](https://pbs.bartificer.net/pbs123) | -| 2021-08-08 | 2.1.2 | Audio/text | [PBS 122 of X — Managing Dot Files with Chezmoi](https://pbs.bartificer.net/pbs122) | -| 2021-07-23 | 2.1.2 | Audio/text | [PBS 121 of X — Managing Dot Files and an Introduction to Chezmoi](https://www.podfeet.com/blog/2021/07/ccatp-693/) | -| 2021-07-15 | 2.1.2 | Text (CN) | [使用Chezmoi管理配置文件](https://marvinsblog.net/post/2021-07-15-chezmoi-intro/) | -| 2021-05-14 | 2.0.12 | Text | [A brief history of my dotfile management](https://jonathanbartlett.co.uk/2021/05/14/a-brief-history-of-my-dotfiles.html) | -| 2021-05-12 | 2.0.12 | Text | [My Dotfiles Story: A Journey to Chezmoi](https://www.mikekasberg.com/blog/2021/05/12/my-dotfiles-story.html) | -| 2021-05-10 | 2.0.11 | Text | [Development Environment (2021)](https://ideas.offby1.net/posts/development-environment-2021.html) | -| 2021-04-20 | 2.0.9 | Text | [ChezMoi](https://johnmathews.eu/chezmoi.html) | -| 2021-04-08 | 2.0.9 | Text (FR) | [Bienvenue chez moi](https://blogduyax.madyanne.fr/2021/bienvenue-chez-moi/) | -| 2021-04-01 | 2.0.7 | Text | [ChezMoi](https://johnmathews.is/chezmoi.html) | -| 2021-02-17 | 1.8.11 | Text (JP) | [chezmoi で dotfiles を手軽に柔軟にセキュアに管理する](https://zenn.dev/ryo_kawamata/articles/introduce-chezmoi) | -| 2021-02-07 | 1.8.10 | Text (JP) | [chezmoi始めた](https://joe-noh.hatenablog.com/entry/2021/02/07/215733) | -| 2021-02-06 | 1.8.10 | Video | [chezmoi: manage your dotfiles across multiple, diverse machines, securely](https://fosdem.org/2021/schedule/event/chezmoi/) | -| 2021-01-29 | 1.8.10 | Text (CN) | [[归档] 用 Chezmoi 管理配置文件](https://axionl.me/p/%E5%BD%92%E6%A1%A3-%E7%94%A8-chezmoi-%E7%AE%A1%E7%90%86%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6/) | -| 2021-01-12 | 1.8.10 | Text | [Automating the Setup of a New Mac With All Your Apps, Preferences, and Development Tools](https://www.moncefbelyamani.com/automating-the-setup-of-a-new-mac-with-all-your-apps-preferences-and-development-tools/) | -| 2020-11-06 | 1.8.8 | Text | [Chezmoi – Securely Manage dotfiles across multiple machines](https://computingforgeeks.com/chezmoi-manage-dotfiles-across-multiple-machines/) | -| 2020-11-05 | 1.8.8 | Text | [Using chezmoi to manage dotfiles](https://pashinskikh.de/posts/chezmoi/) | -| 2020-10-05 | 1.8.6 | Text | [Dotfiles with Chezmoi](https://blog.lazkani.io/posts/backup/dotfiles-with-chezmoi/) | -| 2020-10-03 | 1.8.6 | Text | [Chezmoi Merging](https://benoit.srht.site/2020-10-03-chezmoi-merging/) | -| 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) | -| 2020-08-09 | 1.8.3 | Text | [Automating and testing dotfiles](https://seds.nl/posts/automating-and-testing-dotfiles/) | -| 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/) | -| 2020-07-06 | 1.8.3 | Video | [Conf42: chezmoi: Manage your dotfiles across multiple machines, securely](https://www.youtube.com/watch?v=JrCMCdvoMAw) | -| 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) | -| 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/) | -| 2020-04-27 | 1.8.0 | Text | [Managing my dotfiles with chezmoi](http://blog.emilieschario.com/post/managing-my-dotfiles-with-chezmoi/) | -| 2020-04-20 | 1.8.0 | Text (FR) | [Gestion des dotfiles et des secrets avec chezmoi](https://blog.arkey.fr/2020/04/01/manage_dotfiles_with_chezmoi.fr/) | -| 2020-04-19 | 1.7.19 | Text (FR) | [Git & dotfiles : versionner ses fichiers de configuration](https://www.armandphilippot.com/dotfiles-git-fichiers-configuration/) | -| 2020-04-16 | 1.7.19 | Text (FR) | [Chezmoi, visite guidée](https://blog.wescale.fr/2020/04/16/chezmoi-visite-guidee/) | -| 2020-04-17 | 1.7.17 | Text (CN) | [用 Chezmoi 取回你的点文件 | Linux 中国](https://blog.csdn.net/F8qG7f9YD02Pe/article/details/105548429) | -| 2020-04-03 | 1.7.17 | Text | [Fedora Magazine: Take back your dotfiles with Chezmoi](https://fedoramagazine.org/take-back-your-dotfiles-with-chezmoi/) | -| 2020-04-01 | 1.7.17 | Text | [Managing dotfiles and secret with chezmoi](https://blog.arkey.fr/2020/04/01/manage_dotfiles_with_chezmoi/) | -| 2020-03-12 | 1.7.16 | Video | [Managing Dotfiles with ChezMoi](https://www.youtube.com/watch?v=HXx6ugA98Qo) | -| 2019-11-20 | 1.7.2 | Audio/video | [FLOSS weekly episode 556: chezmoi](https://twit.tv/shows/floss-weekly/episodes/556) | -| 2019-01-10 | 0.0.11 | Text | [Linux Fu: The kitchen sync](https://hackaday.com/2019/01/10/linux-fu-the-kitchen-sync/) | - ---- - -To add your article to this page please either [open an -issue](https://github.com/twpayne/chezmoi/issues/new/choose) or submit a pull -request that modifies this file -([`docs/MEDIA.md`](https://github.com/twpayne/chezmoi/blob/master/docs/MEDIA.md)). - ---- diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md deleted file mode 100644 index d1a816308ce..00000000000 --- a/docs/QUICKSTART.md +++ /dev/null @@ -1,157 +0,0 @@ -# chezmoi quick start guide - - -* [Concepts](#concepts) -* [Start using chezmoi on your current machine](#start-using-chezmoi-on-your-current-machine) -* [Using chezmoi across multiple machines](#using-chezmoi-across-multiple-machines) -* [Next steps](#next-steps) - ---- - -## Concepts - -chezmoi stores the desired state of your dotfiles in the directory -`~/.local/share/chezmoi`. When you run `chezmoi apply`, chezmoi calculates the -desired contents and permissions for each dotfile and then makes any changes -necessary so that your dotfiles match that state. - ---- - -## Start using chezmoi on your current machine - -Assuming that you have already [installed -chezmoi](https://github.com/twpayne/chezmoi/blob/master/docs/INSTALL.md), -initialize chezmoi with: - -```console -$ chezmoi init -``` - -This will create a new git repository in `~/.local/share/chezmoi` where chezmoi -will store its source state. By default, chezmoi only modifies files in the -working copy. It is your responsibility to commit and push changes, but chezmoi -can automate this for you if you want. - -Manage your first file with chezmoi: - -```console -$ chezmoi add ~/.bashrc -``` - -This will copy `~/.bashrc` to `~/.local/share/chezmoi/dot_bashrc`. - -Edit the source state: - -```console -$ chezmoi edit ~/.bashrc -``` - -This will open `~/.local/share/chezmoi/dot_bashrc` in your `$EDITOR`. Make some -changes and save the file. - -See what changes chezmoi would make: - -```console -$ chezmoi diff -``` - -Apply the changes: - -```console -$ chezmoi -v apply -``` - -All chezmoi commands accept the `-v` (verbose) flag to print out exactly what -changes they will make to the file system, and the `-n` (dry run) flag to not -make any actual changes. The combination `-n` `-v` is very useful if you want to -see exactly what changes would be made. - -Next, open a shell in the source directory, to commit your changes: - -```console -$ chezmoi cd -$ git add . -$ git commit -m "Initial commit" -``` - -[Create a new repository on GitHub](https://github.com/new) called `dotfiles` -and then push your repo: - -```console -$ git remote add origin git@github.com:username/dotfiles.git -$ git branch -M main -$ git push -u origin main -``` - -chezmoi can also be used with [GitLab](https://gitlab.com), or -[BitBucket](https://bitbucket.org), [Source Hut](https://sr.ht/), or any other -git hosting service. - -Finally, exit the shell in the source directory to return to where you were: - -```console -$ exit -``` - ---- - -## Using chezmoi across multiple machines - -On a second machine, initialize chezmoi with your dotfiles repo: - -```console -$ chezmoi init https://github.com/username/dotfiles.git -``` - -This will check out the repo and any submodules and optionally create a chezmoi -config file for you. - -Check what changes that chezmoi will make to your home directory by running: - -```console -$ chezmoi diff -``` - -If you are happy with the changes that chezmoi will make then run: - -```console -$ chezmoi apply -v -``` - -If you are not happy with the changes to a file then either edit it with: - -```console -$ chezmoi edit $FILE -``` - -Or, invoke a merge tool (by default `vimdiff`) to merge changes between the -current contents of the file, the file in your working copy, and the computed -contents of the file: - -```console -$ chezmoi merge $FILE -``` - -On any machine, you can pull and apply the latest changes from your repo with: - -```console -$ chezmoi update -v -``` - ---- - -## Next steps - -For a full list of commands run: - -```console -$ chezmoi help -``` - -chezmoi has much more functionality. Good starting points are reading [articles -about chezmoi](https://github.com/twpayne/chezmoi/blob/master/docs/MEDIA.md) -adding more dotfiles, and using templates to manage files that vary from machine -to machine and retrieve secrets from your password manager. Read the [how-to -guide](https://github.com/twpayne/chezmoi/blob/master/docs/HOWTO.md) to explore. - ---- diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md deleted file mode 100644 index 3bc3db13667..00000000000 --- a/docs/REFERENCE.md +++ /dev/null @@ -1,2654 +0,0 @@ -# chezmoi reference manual - -Manage your dotfiles across multiple machines, securely. - - -* [Concepts](#concepts) -* [Global command line flags](#global-command-line-flags) - * [`--color` *value*](#--color-value) - * [`-c`, `--config` *filename*](#-c---config-filename) - * [`--config-format` `json`|`toml`|`yaml`](#--config-format-jsontomlyaml) - * [`-D`, `--destination` *directory*](#-d---destination-directory) - * [`-n`, `--dry-run`](#-n---dry-run) - * [`--force`](#--force) - * [`-h`, `--help`](#-h---help) - * [`-k`, `--keep-going`](#-k---keep-going) - * [`--no-pager`](#--no-pager) - * [`--no-tty`](#--no-tty) - * [`-o`, `--output` *filename*](#-o---output-filename) - * [`--persistent-state` *filename*](#--persistent-state-filename) - * [`-R`, `--refresh-externals`](#-r---refresh-externals) - * [`-S`, `--source` *directory*](#-s---source-directory) - * [`--use-builtin-age` *value*](#--use-builtin-age-value) - * [`--use-builtin-git` *value*](#--use-builtin-git-value) - * [`-v`, `--verbose`](#-v---verbose) - * [`--version`](#--version) - * [`-w`, `--working-tree` *directory*](#-w---working-tree-directory) -* [Common command line flags](#common-command-line-flags) - * [`-f`, `--format` `json`|`yaml`](#-f---format-jsonyaml) - * [`-i`, `--include` *types*](#-i---include-types) - * [`-r`, `--recursive`](#-r---recursive) - * [`-x`, `--exclude` *types*](#-x---exclude-types) -* [Developer command line flags](#developer-command-line-flags) - * [`--cpu-profile` *filename*](#--cpu-profile-filename) - * [`--debug`](#--debug) - * [`--gops`](#--gops) -* [Configuration file](#configuration-file) - * [Variables](#variables) - * [Examples](#examples) -* [Source state attributes](#source-state-attributes) -* [Target types](#target-types) - * [Files](#files) - * [Directories](#directories) - * [Symbolic links](#symbolic-links) - * [Scripts](#scripts) - * [`symlink` mode](#symlink-mode) -* [Application order](#application-order) -* [Special files and directories](#special-files-and-directories) - * [`.chezmoi..tmpl`](#chezmoiformattmpl) - * [`.chezmoidata.`](#chezmoidataformat) - * [`.chezmoiexternal.`](#chezmoiexternalformat) - * [`.chezmoiignore`](#chezmoiignore) - * [`.chezmoiremove`](#chezmoiremove) - * [`.chezmoiroot`](#chezmoiroot) - * [`.chezmoitemplates`](#chezmoitemplates) - * [`.chezmoiversion`](#chezmoiversion) -* [Commands](#commands) - * [`add` *target*...](#add-target) - * [`apply` [*target*...]](#apply-target) - * [`archive`](#archive) - * [`cat` *target*...](#cat-target) - * [`cd`](#cd) - * [`chattr` *modifier* *target*...](#chattr-modifier-target) - * [`completion` *shell*](#completion-shell) - * [`data`](#data) - * [`decrypt` [*file*...]](#decrypt-file) - * [`diff` [*target*...]](#diff-target) - * [`docs` [*regexp*]](#docs-regexp) - * [`doctor`](#doctor) - * [`dump` [*target*...]](#dump-target) - * [`edit` [*target*...]](#edit-target) - * [`edit-config`](#edit-config) - * [`encrypt` [*file*...]](#encrypt-file) - * [`execute-template` [*template*...]](#execute-template-template) - * [`forget` *targets*](#forget-targets) - * [`git` [*arg*...]](#git-arg) - * [`help` [*command*...]](#help-command) - * [`init` [*repo*]](#init-repo) - * [`import` *filename*](#import-filename) - * [`manage` *targets*](#manage-targets) - * [`managed`](#managed) - * [`merge` *target*...](#merge-target) - * [`merge-all` [*target*...]](#merge-all-target) - * [`purge`](#purge) - * [`remove` *targets*](#remove-targets) - * [`re-add`](#re-add) - * [`rm` *targets*](#rm-targets) - * [`secret`](#secret) - * [`source-path` [*target*...]](#source-path-target) - * [`state`](#state) - * [`status`](#status) - * [`unmanage` *target*...](#unmanage-target) - * [`unmanaged`](#unmanaged) - * [`update`](#update) - * [`upgrade`](#upgrade) - * [`verify` [*target*...]](#verify-target) -* [Editor configuration](#editor-configuration) -* [pinentry configuration](#pinentry-configuration) - * [Example pinentry configuration](#example-pinentry-configuration) -* [Umask configuration](#umask-configuration) -* [Template execution](#template-execution) -* [Template variables](#template-variables) -* [Template functions](#template-functions) - * [`bitwarden` [*arg*...]](#bitwarden-arg) - * [`bitwardenAttachment` *filename* *itemid*](#bitwardenattachment-filename-itemid) - * [`bitwardenFields` [*arg*...]](#bitwardenfields-arg) - * [`decrypt` *ciphertext*](#decrypt-ciphertext) - * [`encrypt` *plaintext*](#encrypt-plaintext) - * [`fromYaml` *yamltext*](#fromyaml-yamltext) - * [`gitHubKeys` *user*](#githubkeys-user) - * [`gitHubLatestRelease` *user-repo*](#githublatestrelease-user-repo) - * [`gopass` *gopass-name*](#gopass-gopass-name) - * [`gopassRaw` *gopass-name*](#gopassraw-gopass-name) - * [`include` *filename*](#include-filename) - * [`ioreg`](#ioreg) - * [`joinPath` *element*...](#joinpath-element) - * [`keepassxc` *entry*](#keepassxc-entry) - * [`keepassxcAttribute` *entry* *attribute*](#keepassxcattribute-entry-attribute) - * [`keyring` *service* *user*](#keyring-service-user) - * [`lastpass` *id*](#lastpass-id) - * [`lastpassRaw` *id*](#lastpassraw-id) - * [`lookPath` *file*](#lookpath-file) - * [`mozillaInstallHash` *path*](#mozillainstallhash-path) - * [`onepassword` *uuid* [*vault-uuid* [*account-name*]]](#onepassword-uuid-vault-uuid-account-name) - * [`onepasswordDocument` *uuid* [*vault-uuid* [*account-name*]]](#onepassworddocument-uuid-vault-uuid-account-name) - * [`onepasswordDetailsFields` *uuid* [*vault-uuid* [*account-name*]]](#onepassworddetailsfields-uuid-vault-uuid-account-name) - * [`onepasswordItemFields` *uuid* [*vault-uuid* [*account-name*]]](#onepassworditemfields-uuid-vault-uuid-account-name) - * [`output` *name* [*arg*...]](#output-name-arg) - * [`pass` *pass-name*](#pass-pass-name) - * [`passRaw` *pass-name*](#passraw-pass-name) - * [`promptBool` *prompt* [*default*]](#promptbool-prompt-default) - * [`promptInt` *prompt* [*default*]](#promptint-prompt-default) - * [`promptString` *prompt* [*default*]](#promptstring-prompt-default) - * [`secret` [*arg*...]](#secret-arg) - * [`secretJSON` [*arg*...]](#secretjson-arg) - * [`stat` *name*](#stat-name) - * [`stdinIsATTY`](#stdinisatty) - * [`toYaml` *value*](#toyaml-value) - * [`vault` *key*](#vault-key) - * [`writeToStdout` *string*...](#writetostdout-string) - ---- - -## Concepts - -chezmoi evaluates the source state for the current machine and then updates the -destination directory, where: - -* The *source state* declares the desired state of your home directory, - including templates and machine-specific configuration. - -* The *source directory* is where chezmoi stores the source state, by default - `~/.local/share/chezmoi`. - -* The *target state* is the source state computed for the current machine. - -* The *destination directory* is the directory that chezmoi manages, by default - your home directory. - -* A *target* is a file, directory, or symlink in the destination directory. - -* The *destination state* is the current state of all the targets in the - destination directory. - -* The *config file* contains machine-specific configuration, by default it is - `~/.config/chezmoi/chezmoi.toml`. - -* The *working tree* is the git working tree. Normally it is the same as the - source directory, but can be a parent of the source directory. - ---- - -## Global command line flags - -Command line flags override any values set in the configuration file. - -### `--color` *value* - -Colorize diffs, *value* can be `on`, `off`, `auto`, or any boolean-like value -recognized by `parseBool`. The default is `auto` which will colorize diffs only -if the the environment variable `$NO_COLOR` is not set and stdout is a terminal. - -### `-c`, `--config` *filename* - -Read the configuration from *filename*. - -### `--config-format` `json`|`toml`|`yaml` - -Assume the configuration file is in the given format. This is only needed if the -config filename does not have an extension, for example when it is `/dev/stdin`. - -### `-D`, `--destination` *directory* - -Use *directory* as the destination directory. - -### `-n`, `--dry-run` - -Set dry run mode. In dry run mode, the destination directory is never modified. -This is most useful in combination with the `-v` (verbose) flag to print changes -that would be made without making them. - -### `--force` - -Make changes without prompting. - -### `-h`, `--help` - -Print help. - -### `-k`, `--keep-going` - -Keep going as far as possible after a encountering an error. - -### `--no-pager` - -Do not use the pager. - -### `--no-tty` - -Do not attempt to get a TTY to read input and passwords. Instead, read them from -stdin. - -### `-o`, `--output` *filename* - -Write the output to *filename* instead of stdout. - -### `--persistent-state` *filename* - -Read and write the persistent state from *filename*. By default, chezmoi stores -its persistent state in `chezmoistate.boltdb` in the same directory as its -configuration file. - -### `-R`, `--refresh-externals` - -Refresh externals cache. See `.chezmoiexternal.`. - -### `-S`, `--source` *directory* - -Use *directory* as the source directory. - -### `--use-builtin-age` *value* - -Use chezmoi's builtin [age encryption](https://age-encryption.org) instead of an -external `age` command. *value* can be `on`, `off`, `auto`, or any boolean-like -value recognized by `parseBool`. The default is `auto` which will only use the -builtin age if `age.command` cannot be found in `$PATH`. - -The builtin `age` command does not support passphrases, symmetric encryption, or -the use of SSH keys. - -### `--use-builtin-git` *value* - -Use chezmoi's builtin git instead of `git.command` for the `init` and `update` -commands. *value* can be `on`, `off`, `auto`, or any boolean-like value -recognized by `parseBool`. The default is `auto` which will only use the builtin -git if `git.command` cannot be found in `$PATH`. - -### `-v`, `--verbose` - -Set verbose mode. In verbose mode, chezmoi prints the changes that it is making -as approximate shell commands, and any differences in files between the target -state and the destination set are printed as unified diffs. - -### `--version` - -Print the version of chezmoi, the commit at which it was built, and the build -timestamp. - -### `-w`, `--working-tree` *directory* - -Use *directory* as the git working tree directory. By default, chezmoi searches -the source directory and then its ancestors for the first directory that -contains a `.git` directory. - ---- - -## Common command line flags - -The following flags apply to multiple commands where they are relevant. - -### `-f`, `--format` `json`|`yaml` - -Set the output format. - -### `-i`, `--include` *types* - -Only operate on target state entries of type *types*. *types* is a -comma-separated list of target states (`all`, `dirs`, `files`, `remove`, -`scripts`, `symlinks`, and `encrypted`) and can be excluded by preceding them -with a `no`. For example, `--include=dirs,files` will cause the command to apply -to directories and files only. - -#### `--init` - -Regenerate and reread the config file from the config file template before -computing the target state. - -### `-r`, `--recursive` - -Recurse into subdirectories, `true` by default. - -### `-x`, `--exclude` *types* - -Exclude target state entries of type *types*. *types* is a comma-separated list -of target states (`all`, `dirs`, `files`, `remove`, `scripts`, `symlinks`, and -`encrypted`). For example, `--exclude=scripts` will cause the command to not run -scripts and `--exclude=encrypted` will exclude encrypted files. - -## Developer command line flags - -The following flags are global but only relevant for developers and debugging. - -### `--cpu-profile` *filename* - -Write a [Go CPU profile](https://blog.golang.org/pprof) to *filename*. - -### `--debug` - -Log information helpful for debugging. - -### `--gops` - -Enable the [gops](https://github.com/google/gops) agent. - ---- - -## Configuration file - -chezmoi searches for its configuration file according to the [XDG Base Directory -Specification](https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html) -and supports [JSON](https://www.json.org/json-en.html), -[TOML](https://github.com/toml-lang/toml), and [YAML](https://yaml.org/). The -basename of the config file is `chezmoi`, and the first config file found is -used. - -In most installations, the config file will be read from -`~/.config/chezmoi/chezmoi.`, where `` is one of `json`, `toml`, -or `yaml`. The config file can be set explicitly with the `--config` command -line option. By default, the format is detected based on the extension of the -config file name, but can be overridden with the `--config-format` command line -option. - -### Variables - -The following configuration variables are available: - -| Section | Variable | Type | Default value | Description | -| -------------- | --------------------- | -------- | ------------------------ | ------------------------------------------------------ | -| Top level | `color` | string | `auto` | Colorize output | -| | `data` | any | *none* | Template data | -| | `destDir` | string | `~` | Destination directory | -| | `encryption` | string | *none* | Encryption tool, either `age` or `gpg` | -| | `format` | string | `json` | Format for data output, either `json` or `yaml` | -| | `mode` | string | `file` | Mode in target dir, either `file` or `symlink` | -| | `sourceDir` | string | `~/.local/share/chezmoi` | Source directory | -| | `pager` | string | `$PAGER` | Default pager | -| | `umask` | int | *from system* | Umask | -| | `useBuiltinAge` | string | `auto` | Use builtin age if `age` command is not found in $PATH | -| | `useBuiltinGit` | string | `auto` | Use builtin git if `git` command is not found in $PATH | -| | `verbose` | bool | `false` | Make output more verbose | -| | `workingTree` | string | *source directory* | git working tree directory | -| `add` | `templateSymlinks` | bool | `false` | Template symlinks to source and home dirs | -| `age` | `args` | []string | *none* | Extra args to age CLI command | -| | `command` | string | `age` | age CLI command | -| | `identity` | string | *none* | age identity file | -| | `identities` | []string | *none* | age identity files | -| | `passphrase` | bool | `false` | Use age passphrase instead of identity | -| | `recipient` | string | *none* | age recipient | -| | `recipients` | []string | *none* | age recipients | -| | `recipientsFile` | []string | *none* | age recipients file | -| | `recipientsFiles` | []string | *none* | age recipients files | -| | `suffix` | string | `.age` | Suffix appended to age-encrypted files | -| | `symmetric` | bool | `false` | Use age symmetric encryption | -| `bitwarden` | `command` | string | `bw` | Bitwarden CLI command | -| `cd` | `args` | []string | *none* | Extra args to shell in `cd` command | -| | `command` | string | *none* | Shell to run in `cd` command | -| `diff` | `args` | []string | *see `diff` below* | Extra args to external diff command | -| | `command` | string | *none* | External diff command | -| | `exclude` | []string | *none* | Entry types to exclude from diffs | -| | `pager` | string | *none* | Diff-specific pager | -| `docs` | `maxWidth` | int | 80 | Maximum width of output | -| | `pager` | string | *none* | Docs-specific pager | -| `edit` | `args` | []string | *none* | Extra args to edit command | -| | `command` | string | `$EDITOR` / `$VISUAL` | Edit command | -| | `hardlink` | bool | `true` | Invoke editor with a hardlink to the source file | -| | `minDuration` | duration | `1s` | Minimum duration for edit command | -| `secret` | `command` | string | *none* | Generic secret command | -| `git` | `autoAdd ` | bool | `false` | Add changes to the source state after any change | -| | `autoCommit` | bool | `false` | Commit changes to the source state after any change | -| | `autoPush` | bool | `false` | Push changes to the source state after any change | -| | `command` | string | `git` | Source version control system | -| `gopass` | `command` | string | `gopass` | gopass CLI command | -| `gpg` | `args` | []string | *none* | Extra args to GPG CLI command | -| | `command` | string | `gpg` | GPG CLI command | -| | `recipient` | string | *none* | GPG recipient | -| | `suffix` | string | `.asc` | Suffix appended to GPG-encrypted files | -| | `symmetric` | bool | `false` | Use symmetric GPG encryption | -| `interpreters` | *extension*`.args` | []string | *none* | See section on "Scripts on Windows" | -| | *extension*`.command` | string | *special* | See section on "Scripts on Windows" | -| `keepassxc` | `args` | []string | *none* | Extra args to KeePassXC CLI command | -| | `command` | string | `keepassxc-cli` | KeePassXC CLI command | -| | `database` | string | *none* | KeePassXC database | -| `lastpass` | `command` | string | `lpass` | Lastpass CLI command | -| `merge` | `args` | []string | *see `merge` below* | Args to 3-way merge command | -| | `command` | string | `vimdiff` | 3-way merge command | -| `onepassword` | `cache` | bool | `true` | Enable optional caching provided by `op` | -| | `command` | string | `op` | 1Password CLI command | -| `pass` | `command` | string | `pass` | Pass CLI command | -| `pinentry` | `args` | []string | *none* | Extra args to the pinentry command | -| | `command` | string | *none* | pinentry command | -| | `options` | []string | *see `pinentry` below* | Extra options for pinentry | -| `template` | `options` | []string | `["missingkey=error"]` | Template options | -| `vault` | `command` | string | `vault` | Vault CLI command | - -### Examples - -#### JSON - -```json -{ - "sourceDir": "/home/user/.dotfiles", - "git": { - "autoPush": true - } -} -``` - -#### TOML - -```toml -sourceDir = "/home/user/.dotfiles" -[git] - autoPush = true -``` - -#### YAML - -```yaml -sourceDir: /home/user/.dotfiles -git: - autoPush: true -``` - ---- - -## Source state attributes - -chezmoi stores the source state of files, symbolic links, and directories in -regular files and directories in the source directory (`~/.local/share/chezmoi` -by default). This location can be overridden with the `-S` flag or by giving a -value for `sourceDir` in `~/.config/chezmoi/chezmoi.toml`. Directory targets are -represented as directories in the source state. All other target types are -represented as files in the source state. Some state is encoded in the source -names. - -The following prefixes and suffixes are special, and are collectively referred -to as "attributes": - -| Prefix | Effect | -| ------------- | ------------------------------------------------------------------------------- | -| `after_` | Run script after updating the destination. | -| `before_` | Run script before updating the destination. | -| `create_` | Ensure that the file exists, and create it with contents if it does not. | -| `dot_` | Rename to use a leading dot, e.g. `dot_foo` becomes `.foo`. | -| `empty_` | Ensure the file exists, even if is empty. By default, empty files are removed. | -| `encrypted_` | Encrypt the file in the source state. | -| `exact_` | Remove anything not managed by chezmoi. | -| `executable_` | Add executable permissions to the target file. | -| `literal_` | Stop parsing prefix attributes. | -| `modify_` | Treat the contents as a script that modifies an existing file. | -| `once_` | Only run the script if it has not been run before. | -| `onchange_` | Only run the script if its contents have changed from the last time it was run. | -| `private_` | Remove all group and world permissions from the target file or directory. | -| `readonly_` | Remove all write permissions from the target file or directory. | -| `remove_` | Remove the entry if it exists. | -| `run_` | Treat the contents as a script to run. | -| `symlink_` | Create a symlink instead of a regular file. | - -| Suffix | Effect | -| ---------- | ---------------------------------------------------- | -| `.literal` | Stop parsing suffix attributes. | -| `.tmpl` | Treat the contents of the source file as a template. | - -Different target types allow different prefixes and suffixes. The order of -prefixes is important. - -| Target type | Source type | Allowed prefixes in order | Allowed suffixes | -| ------------- | ----------- | ----------------------------------------------------------------------- | ---------------- | -| Directory | Directory | `exact_`, `private_`, `readonly_`, `dot_` | *none* | -| Regular file | File | `encrypted_`, `private_`, `executable_`, `dot_` | `.tmpl` | -| Create file | File | `create_`, `encrypted_`, `private_`, `readonly_`, `executable_`, `dot_` | `.tmpl` | -| Modify file | File | `modify_`, `encrypted_`, `private_`, `readonly_`, `executable_`, `dot_` | `.tmpl` | -| Remove | File | `remove_`, `dot_` | *none* | -| Script | File | `run_`, `once_` or `onchange_`, `before_` or `after_` | `.tmpl` | -| Symbolic link | File | `symlink_`, `dot_`, | `.tmpl` | - -The `literal_` prefix and `.literal` suffix can appear anywhere and stop -attribute parsing. This permits filenames that would otherwise conflict with -chezmoi's attributes to be represented. - -In addition, if the source file is encrypted, the suffix `.age` (when age -encryption is used) or `.asc` (when gpg encryption is used) is stripped. These -suffixes can be overridden with the `age.suffix` and `gpg.suffix` configuration -variables. - -chezmoi ignores all files and directories in the source directory that begin -with a `.` with the exception of files and directories that begin with -`.chezmoi`. - ---- - -## Target types - -chezmoi will create, update, and delete files, directories, and symbolic links -in the destination directory, and run scripts. chezmoi deterministically -performs actions in ASCII order of their target name. For example, given a file -`dot_a`, a script `run_z`, and a directory `exact_dot_c`, chezmoi will first -create `.a`, create `.c`, and then execute `run_z`. - ---- - -### Files - -Files are represented by regular files in the source state. The `encrypted_` -attribute determines whether the file in the source state is encrypted. The -`executable_` attribute will set the executable bits in the the target state, -and the `private_` attribute will clear all group and world permissions. The -`readonly_` attribute will clear all write permission bits in the target state. -Files with the `.tmpl` suffix will be interpreted as templates. If the target -contents are empty then the file will be removed, unless it has an `empty_` -prefix. - -#### Create file - -Files with the `create_` prefix will be created in the target state with the -contents of the file in the source state if they do not already exist. If the -file in the destination state already exists then its contents will be left -unchanged. - -#### Modify file - -Files with the `modify_` prefix are treated as scripts that modify an existing -file. The contents of the existing file (which maybe empty if the existing file -does not exist or is empty) are passed to the script's standard input, and the -new contents are read from the scripts standard output. - ---- - -#### Remove entry - -Files with the `remove_` prefix will cause the corresponding entry (file, -directory, or symlink) to be removed in the target state. - ---- - -### Directories - -Directories are represented by regular directories in the source state. The -`exact_` attribute causes chezmoi to remove any entries in the target state that -are not explicitly specified in the source state, and the `private_` attribute -causes chezmoi to clear all group and world permissions. The `readonly_` -attribute will clear all write permission bits. - ---- - -### Symbolic links - -Symbolic links are represented by regular files in the source state with the -prefix `symlink_`. The contents of the file will have a trailing newline -stripped, and the result be interpreted as the target of the symbolic link. -Symbolic links with the `.tmpl` suffix in the source state are interpreted as -templates. If the target of the symbolic link is empty or consists only of -whitespace, then the target is removed. - ---- - -### Scripts - -Scripts are represented as regular files in the source state with prefix `run_`. -The file's contents (after being interpreted as a template if it has a `.tmpl` -suffix) are executed. - -Scripts are executed on every `chezmoi apply`, unless they have the `once_` or -`onchange_` attribute. `run_once_` scripts are only executed if a script with -the same contents has not been run before, for example if its contents has -changed. `run_onchange_` scripts are executed whenever their contents change, -even if a script with the same contents has run before. - -Scripts with the `before_` attribute are executed before any files, directories, -or symlinks are updated. Scripts with the `after_` attribute are executed after -all files, directories, and symlinks have been updated. Scripts without an -`before_` or `after_` attribute are executed in ASCII order of their target -names with respect to files, directories, and symlinks. - -Scripts will normally run with their working directory set to their equivalent -location in the destination directory. For example, a script in -`~/.local/share/chezmoi/dir/run_script` will be run with a working directory of -`~/dir`. If the equivalent location in the destination directory either does not -exist or is not a directory, then chezmoi will walk up the script's directory -hierarchy and run the script in the first directory that exists and is a -directory. - -#### Scripts on Windows - - - -The execution of scripts on Windows depends on the script's file extension. -Windows will natively execute scripts with a `.bat`, `.cmd`, `.com`, and `.exe` -extensions. Other extensions require an interpreter, which must be in your -`%PATH%`. - -The default script interpreters are: - -| Extension | Command | Arguments | -| --------- | ------------ | --------- | -| `.pl` | `perl` | *none* | -| `.py` | `python` | *none* | -| `.ps1` | `powershell` | `-NoLogo` | -| `.rb` | `ruby` | *none* | - -Script interpreters can be added or overridden with the -`interpreters.`*extension* section in the configuration file. Note that the -leading `.` is dropped from *extension*. - -For example to change the Python interpreter to `C:\Python39\python.exe` and add -a Tcl/Tk interpreter, include the following in `~/.config/chezmoi/chezmoi.toml`: - -```toml -[interpreters.py] - command = 'C:\Python39\python.exe' -[interpreters.tcl] - command = "tclsh" -``` - -If the script in the source state is a template (with a `.tmpl` extension), then -chezmoi will strip the `.tmpl` extension and use the next remaining extension to -determine the interpreter to use. - ---- - -### `symlink` mode - -By default, chezmoi will create regular files and directories. Setting `mode = -"symlink"` will make chezmoi behave more like a dotfile manager that uses -symlinks by default, i.e. `chezmoi apply` will make dotfiles symlinks to files -in the source directory if the target is a regular file and is not -encrypted, executable, private, or a template. - ---- - -## Application order - -chezmoi is deterministic in its order of application. The order is: - -1. Read the source state. -2. Read the destination state. -3. Compute the target state. -4. Run `run_before_` scripts in alphabetical order. -5. Update entries in the target state (files, directories, scripts, symlinks, - etc.) in alphabetical order of their target name. -6. Run `run_after_` scripts in alphabetical order. - -Target names are considered after all attributes are stripped. For example, -given `create_alpha` and `modify_dot_beta` in the source state, `.beta` will be -updated before `alpha` because `.beta` sorts before `alpha`. - -chezmoi assumes that the source or destination states are not modified while -chezmoi is being executed. This assumption permits significant performance -improvements, for example allowing chezmoi to only read files from the source -and destination states if they are needed to compute the target state. - -chezmoi's behavior when the above assumptions are violated is undefined. For -example, using a `run_before_` script to update files in the source or -destination states violates the assumption that the source and destination -states do not change while chezmoi is running. - ---- - -## Special files and directories - -All files and directories in the source state whose name begins with `.` are -ignored by default, unless they are one of the special files listed here. -`.chezmoidata.` and `.chezmoitemplates` are read before all other files -so that they can be used in templates. - ---- - -### `.chezmoi..tmpl` - -If a file called `.chezmoi..tmpl` exists then `chezmoi init` will use it -to create an initial config file. `` must be one of the the supported -config file formats, e.g. `json`, `toml`, or `yaml`. - -#### `.chezmoi..tmpl` examples - -``` -{{ $email := promptString "email" -}} -data: - email: {{ $email | quote }} -``` - ---- - -### `.chezmoidata.` - -If a file called `.chezmoidata.` exists in the source state, it is interpreted -as a datasource available in most [templates](TEMPLATING.md#template-data). - -#### `.chezmoidata.` examples - -If `.chezmoidata.toml` contains the following (and no variable is overwritten in later stages): - -```toml -editor = "nvim" -[directions] - up = "k" - down = "j" - right = "l" - left = "h" -``` - -Then the following template: - -``` -EDITOR={{ .editor }} -MOVE_UP={{ .directions.up }} -MOVE_DOWN={{ .directions.down }} -MOVE_RIGHT={{ .directions.right }} -MOVE_LEFT={{ .directions.left }} -``` - -Will result in: - -``` -EDITOR=nvim -MOVE_UP=k -MOVE_DOWN=j -MOVE_RIGHT=l -MOVE_LEFT=h -``` - ---- - -### `.chezmoiexternal.` - -If a file called `.chezmoiexternal.` exists in the source state, it is -interpreted as a list of external files and archives to be included as if they -were in the source state. - -`` must be one of chezmoi's supported configuration file formats, e.g. -`json`, `toml`, or `yaml`. - -`.chezmoiexternal.` is interpreted as a template. This allows different -externals to be included on different machines. - -Entries are indexed by target name relative to the directory of the -`.chezmoiexternal.` file, and must have a `type` and a `url` field. -`type` can be either `file` or `archive`. If the entry's parent directories do -not already exist in the source state then chezmoi will create them as regular -directories. - -Entries may have the following fields: - -| Variable | Type | Default value | Description | -| ----------------- | -------- | ------------- | ------------------------------------------------------------- | -| `type` | string | *none* | External type (`file` or `archive`) | -| `encrypted` | bool | `false` | Whether the external is encrypted | -| `exact` | bool | `false` | Add `exact_` attribute to directories in archive | -| `executable` | bool | `false` | Add `executable_` attribute to file | -| `filter.command` | string | *none* | Command to filter contents | -| `filter.args` | []string | *none* | Extra args to command to filter contents | -| `format` | string | *autodetect* | Format of archive | -| `refreshPeriod` | duration | `0` | Refresh period | -| `stripComponents` | int | `0` | Number of leading directory components to strip from archives | -| `url` | string | *none* | URL | - -The optional boolean `encrypted` field specifies whether the file or archive -is encrypted. - -If optional string `filter.command` and array of strings `filter.args` are -specified, the the file or archive is filtered by piping it into the command's -standard input and reading the command's standard output. - -If `type` is `file` then the target is a file with the contents of `url`. The -optional boolean field `executable` may be set, in which case the target file -will be executable. - -If `type` is `archive` then the target is a directory with the contents of the -archive at `url`. The optional boolean field `exact` may be set, in which case -the directory and all subdirectories will be treated as exact directories, i.e. -`chezmoi apply` will remove entries not present in the archive. The optional -integer field `stripComponents` will remove leading path components from the -members of archive. The optional string field `format` sets the archive format. -The supported archive formats are `tar`, `tar.gz`, `tgz`, `tar.bz2`, `tbz2`, and -`zip`. If `format` is not specified then chezmoi will guess the format using -firstly the path of the URL and secondly its contents. - -By default, chezmoi will cache downloaded URLs. The optional duration -`refreshPeriod` field specifies how often chezmoi will re-download the URL. The -default is zero meaning that chezmoi will never re-download unless forced. To -force chezmoi to re-download URLs, pass the `-R`/`--refresh-externals` flag. -Suitable refresh periods include one day (`24h`), one week (`168h`), or four -weeks (`672h`). - -#### `.chezmoiexternal.` examples - -```toml -[".vim/autoload/plug.vim"] - type = "file" - url = "https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim" - refreshPeriod = "168h" -[".oh-my-zsh"] - type = "archive" - url = "https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz" - exact = true - stripComponents = 1 - refreshPeriod = "168h" -[".oh-my-zsh/custom/plugins/zsh-syntax-highlighting"] - type = "archive" - url = "https://github.com/zsh-users/zsh-syntax-highlighting/archive/master.tar.gz" - exact = true - stripComponents = 1 - refreshPeriod = "168h" -[".oh-my-zsh/custom/themes/powerlevel10k"] - type = "archive" - url = "https://github.com/romkatv/powerlevel10k/archive/v1.15.0.tar.gz" - exact = true - stripComponents = 1 -``` - ---- - -### `.chezmoiignore` - -If a file called `.chezmoiignore` exists in the source state then it is -interpreted as a set of patterns to ignore. Patterns are matched using -[`doublestar.Match`](https://pkg.go.dev/github.com/bmatcuk/doublestar/v4#Match) -and match against the target path, not the source path. - -Patterns can be excluded by prefixing them with a `!` character. All excludes -take priority over all includes. - -Comments are introduced with the `#` character and run until the end of the -line. - -`.chezmoiignore` is interpreted as a template. This allows different files to be -ignored on different machines. - -`.chezmoiignore` files in subdirectories apply only to that subdirectory. - -#### `.chezmoiignore` examples - -``` -README.md - -*.txt # ignore *.txt in the target directory -*/*.txt # ignore *.txt in subdirectories of the target directory - # but not in subdirectories of subdirectories; - # so a/b/c.txt would *not* be ignored - -backups/ # ignore backups folder in chezmoi directory and all its contents -backups/** # ignore all contents of backups folder in chezmoi directory - # but not backups folder itself - -{{- if ne .email "firstname.lastname@company.com" }} -# Ignore .company-directory unless configured with a company email -.company-directory # note that the pattern is not dot_company-directory -{{- end }} - -{{- if ne .email "me@home.org }} -.personal-file -{{- end }} -``` - ---- - -### `.chezmoiremove` - -If a file called `.chezmoiremove` exists in the source state then it is -interpreted as a list of targets to remove. `.chezmoiremove` is interpreted as a -template. - ---- - -### `.chezmoiroot` - -If a file called `.chezmoiroot` exists in the root of the source directory then -the source state is read from the directory specified in `.chezmoiroot` -interpreted as a relative path to the source directory. `.chezmoiroot` is read -before all other files in the source directory. - ---- - -### `.chezmoitemplates` - -If a directory called `.chezmoitemplates` exists, then all files in this -directory are parsed as templates are available as templates with a name equal -to the relative path to the `.chezmoitemplates` directory. - -The [`template` action](https://pkg.go.dev/text/template#hdr-Actions) can be -used to include these templates in another template. The value of `.` must be -set explicitly if needed, otherwise the template will be executed with `nil` -data. - -#### `.chezmoitemplates` examples - -Given: - -`.chezmoitemplates/foo`: -``` -{{ if true }}bar{{ end }} -``` - -`dot_config.tmpl`: -``` -{{ template "foo" . }} -``` - -The target state of `.config` will be `bar`. - ---- - -### `.chezmoiversion` - -If a file called `.chezmoiversion` exists, then its contents are interpreted as -a semantic version defining the minimum version of chezmoi required to interpret -the source state correctly. chezmoi will refuse to interpret the source state if -the current version is too old. - -#### `.chezmoiversion` examples - -``` -1.5.0 -``` - ---- - -## Commands - ---- - -### `add` *target*... - -Add *target*s to the source state. If any target is already in the source state, -then its source state is replaced with its current state in the destination -directory. - -#### `--autotemplate` - -Automatically generate a template by replacing strings with variable names from -the `data` section of the config file. Longer substitutions occur before shorter -ones. This implies the `--template` option. - -#### `-e`, `--empty` - -Set the `empty` attribute on added files. - -#### `-f`, `--force` - -Add *targets*, even if doing so would cause a source template to be overwritten. - -#### `--follow` - -If the last part of a target is a symlink, add the target of the symlink instead -of the symlink itself. - -#### `--exact` - -Set the `exact` attribute on added directories. - -#### `-i`, `--include` *types* - -Only add entries of type *types*. - -#### `-p`, `--prompt` - -Interactively prompt before adding each file. - -#### `-r`, `--recursive` - -Recursively add all files, directories, and symlinks. - -#### `-T`, `--template` - -Set the `template` attribute on added files and symlinks. - -#### `--template-symlinks` - -When adding symlink to an absolute path in the source directory or destination -directory, create a symlink template with `.chezmoi.sourceDir` or -`.chezmoi.homeDir`. This is useful for creating portable absolute symlinks. - -#### `add` examples - -```console -$ chezmoi add ~/.bashrc -$ chezmoi add ~/.gitconfig --template -$ chezmoi add ~/.vim --recursive -$ chezmoi add ~/.oh-my-zsh --exact --recursive -``` - ---- - -### `apply` [*target*...] - -Ensure that *target*... are in the target state, updating them if necessary. If -no targets are specified, the state of all targets are ensured. If a target has -been modified since chezmoi last wrote it then the user will be prompted if they -want to overwrite the file. - -#### `-i`, `--include` *types* - -Only add entries of type *types*. - -#### `--source-path` - -Specify targets by source path, rather than target path. This is useful for -applying changes after editing. - -#### `apply` examples - -```console -$ chezmoi apply -$ chezmoi apply --dry-run --verbose -$ chezmoi apply ~/.bashrc -``` - ---- - -### `archive` - -Generate an archive of the target state. This can be piped into `tar` to inspect -the target state. - -#### `-f`, `--format` `tar`|`tar.gz`|`tgz`|`zip` - -Write the archive in *format*. If `--output` is set the format is guessed from -the extension, otherwise the default is `tar`. - -#### `-i`, `--include` *types* - -Only include entries of type *types*. - -#### `-z`, `--gzip` - -Compress the archive with gzip. This is automatically set if the format is -`tar.gz` or `tgz` and is ignored if the format is `zip`. - -#### `archive` examples - -```console -$ chezmoi archive | tar tvf - -$ chezmoi archive --output=dotfiles.tar.gz -$ chezmoi archive --output=dotfiles.zip -``` - ---- - -### `cat` *target*... - -Write the target contents of *target*s to stdout. *targets* must be files, -scripts, or symlinks. For files, the target file contents are written. For -scripts, the script's contents are written. For symlinks, the target target is -written. - -#### `cat` examples - -```console -$ chezmoi cat ~/.bashrc -``` - ---- - -### `cd` - -Launch a shell in the working tree (typically the source directory). chezmoi -will launch the command set by the `cd.command` configuration variable with any -extra arguments specified by `cd.args`. If this is not set, chezmoi will attempt -to detect your shell and will finally fall back to an OS-specific default. - -#### `cd` examples - -```console -$ chezmoi cd -``` - ---- - -### `chattr` *modifier* *target*... - -Change the attributes and/or type of *target*s. *modifier* specifies what to -modify. - -Add attributes by specifying them or their abbreviations directly, optionally -prefixed with a plus sign (`+`). Remove attributes by prefixing them or their -attributes with the string `no` or a minus sign (`-`). The available attribute -modifiers and their abbreviations are: - -| Attribute modifier | Abbreviation | -| ------------------ | ------------ | -| `after` | `a` | -| `before` | `b` | -| `empty` | `e` | -| `encrypted` | *none* | -| `exact` | *none* | -| `executable` | `x` | -| `once` | `o` | -| `private` | `p` | -| `readonly` | `r` | -| `template` | `t` | - -The type of a target can be changed using a type modifier: - -| Type modifier | -| ------------- | -| `create` | -| `modify` | -| `script` | -| `symlink` | - -The negative form of type modifiers, e.g. `nocreate`, changes the target to be a -regular file if it is of that type, otherwise the type is left unchanged. - -Multiple modifications may be specified by separating them with a comma (`,`). -If you use the `-`*modifier* form then you must put *modifier* after a `--` to -prevent chezmoi from interpreting `-`*modifier* as an option. - -#### `chattr` examples - -```console -$ chezmoi chattr template ~/.bashrc -$ chezmoi chattr noempty ~/.profile -$ chezmoi chattr private,template ~/.netrc -$ chezmoi chattr -- -x ~/.zshrc -$ chezmoi chattr +create,+private ~/.kube/config -``` - ---- - -### `completion` *shell* - -Generate shell completion code for the specified shell (`bash`, `fish`, -`powershell`, or `zsh`). - -#### `completion` examples - -```console -$ chezmoi completion bash -$ chezmoi completion fish --output=~/.config/fish/completions/chezmoi.fish -``` - ---- - -### `data` - -Write the computed template data to stdout. - -#### `-f`, `--format` `json`|`yaml` - -Set the output format. - -#### `data` examples - -```console -$ chezmoi data -$ chezmoi data --format=yaml -``` - ---- - -### `decrypt` [*file*...] - -Decrypt *file*s using chezmoi's configured encryption. If no files are given, -decrypt the standard input. The decrypted result is written to the standard -output or a file if the `--output` flag is set. - ---- - -### `diff` [*target*...] - -Print the difference between the target state and the destination state for -*target*s. If no targets are specified, print the differences for all targets. - -If a `diff.pager` command is set in the configuration file then the output will -be piped into it. - -If `diff.command` is set then it will be invoked to show individual file -differences with `diff.args` passed as arguments. Each element of `diff.args` is -interpreted as a template with the variables `.Destination` and `.Target` -available corresponding to the path of the file in the source and target state -respectively. The default value of `diff.args` is `["{{ .Destination }}", "{{ -.Target }}"]`. If `diff.args` does not contain any template arguments then `{{ -.Destination }}` and `{{ .Target }}` will be appended automatically. - -#### `--reverse` - -Reverse the direction of the diff, i.e. show the changes to the target required -to match the destination. - -#### `--pager` *pager* - -Pager to use for output. - -#### `--use-builtin-diff` - -Use chezmoi's builtin diff, even if the `diff.command` configuration variable is -set. - -#### `diff` examples - -```console -$ chezmoi diff -$ chezmoi diff ~/.bashrc -``` - ---- - -### `docs` [*regexp*] - -Print the documentation page matching the regular expression *regexp*. Matching -is case insensitive. If no pattern is given, print `REFERENCE.md`. - -#### `--pager` *pager* - -Pager to use for output. - -#### `docs` examples - -```console -$ chezmoi docs -$ chezmoi docs faq -$ chezmoi docs howto -``` - ---- - -### `doctor` - -Check for potential problems. - -#### `doctor` examples - -```console -$ chezmoi doctor -``` - ---- - -### `dump` [*target*...] - -Dump the target state of *target*s. If no targets are specified, then the entire -target state. - -#### `-f`, `--format` `json`|`yaml` - -Set the output format. - -#### `-i`, `--include` *types* - -Only include entries of type *types*. - -#### `dump` examples - -```console -$ chezmoi dump ~/.bashrc -$ chezmoi dump --format=yaml -``` - ---- - -### `edit` [*target*...] - -Edit the source state of *target*s, which must be files or symlinks. If no -targets are given then the working tree of the source directory is opened. - -The editor used is the first non-empty string of the `edit.command` -configuration variable, the `$VISUAL` environment variable, the `$EDITOR` -environment variable. If none are set then chezmoi falls back to `notepad.exe` -on Windows systems and `vi` on non-Windows systems. - -When the `edit.command` configuration variable is used, extra arguments can be -passed to the editor with the `editor.args` configuration variable. - -Encrypted files are decrypted to a private temporary directory and the editor is -invoked with the decrypted file. When the editor exits the edited decrypted file -is re-encrypted and replaces the original file in the source state. - -If the operating system supports hard links, then the edit command invokes the -editor with filenames which match the target filename, unless the -`edit.hardlink` configuration variable is set to `false` the `--hardlink=false` -command line flag is set. - -chezmoi will emit a warning if the editor returns in less than -`edit.minDuration` (default `1s`). To disable this warning, set -`edit.minDuration` to `0`. - -#### `-a`, `--apply` - -Apply target immediately after editing. Ignored if there are no targets. - -#### `--hardlink` *bool* - -Invoke the editor with a hard link to the source file with a name matching the -target filename. This can help the editor determine the type of the file -correctly. This is the default. -#### `edit` examples - -```console -$ chezmoi edit ~/.bashrc -$ chezmoi edit ~/.bashrc --apply -$ chezmoi edit -``` - ---- - -### `edit-config` - -Edit the configuration file. - -#### `edit-config` examples - -```console -$ chezmoi edit-config -``` - ---- - -### `encrypt` [*file*...] - -Encrypt *file*s using chezmoi's configured encryption. If no files are given, -encrypt the standard input. The encrypted result is written to the standard -output or a file if the `--output` flag is set. - ---- - -### `execute-template` [*template*...] - -Execute *template*s. This is useful for testing templates or for calling chezmoi -from other scripts. *templates* are interpreted as literal templates, with no -whitespace added to the output between arguments. If no templates are specified, -the template is read from stdin. - -#### `--init`, `-i` - -Include simulated functions only available during `chezmoi init`. - -#### `--promptBool` *pairs* - -Simulate the `promptBool` function with a function that returns values from -*pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If -`promptBool` is called with a *prompt* that does not match any of *pairs*, then -it returns false. - -#### `--promptInt` *pairs* - -Simulate the `promptInt` function with a function that returns values from -*pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If -`promptInt` is called with a *prompt* that does not match any of *pairs*, then -it returns zero. - -#### `--promptString`, `-p` *pairs* - -Simulate the `promptString` function with a function that returns values from -*pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If -`promptString` is called with a *prompt* that does not match any of *pairs*, -then it returns *prompt* unchanged. - -#### `--stdinisatty` *bool* - -Simulate the `stdinIsATTY` function by returning *bool*. - -#### `execute-template` examples - -```console -$ chezmoi execute-template '{{ .chezmoi.sourceDir }}' -$ chezmoi execute-template '{{ .chezmoi.os }}' / '{{ .chezmoi.arch }}' -$ echo '{{ .chezmoi | toJson }}' | chezmoi execute-template -$ chezmoi execute-template --init --promptString email=me@home.org < ~/.local/share/chezmoi/.chezmoi.toml.tmpl -``` - ---- - -### `forget` *targets* - -Remove *targets* from the source state, i.e. stop managing them. - -#### `forget` examples - -```console -$ chezmoi forget ~/.bashrc -``` - ---- - -### `git` [*arg*...] - -Run `git` *arg*s in the working tree (typically the source directory). Note that -flags in *arguments* must occur after `--` to prevent chezmoi from interpreting -them. - -#### `git` examples - -```console -$ chezmoi git add . -$ chezmoi git add dot_gitconfig -$ chezmoi git -- commit -m "Add .gitconfig" -``` - ---- - -### `help` [*command*...] - -Print the help associated with *command*, or general help if no command is -given. - ---- - -### `init` [*repo*] - -Setup the source directory, generate the config file, and optionally update the -destination directory to match the target state. *repo* is expanded to a full -git repo URL, using HTTPS by default, or SSH if the `--ssh` option is specified, -according to the following patterns: - -| Pattern | HTTPS Repo | SSH repo | -| ------------------ | -------------------------------------- | ---------------------------------- | -| `user` | `https://github.com/user/dotfiles.git` | `git@github.com:user/dotfiles.git` | -| `user/repo` | `https://github.com/user/repo.git` | `git@github.com:user/repo.git` | -| `site/user/repo` | `https://site/user/repo.git` | `git@site:user/repo.git` | -| `~sr.ht/user` | `https://git.sr.ht/~user/dotfiles` | `git@git.sr.ht:~user/dotfiles.git` | -| `~sr.ht/user/repo` | `https://git.sr.ht/~user/repo` | `git@git.sr.ht:~/user/repo.git` | - -First, if the source directory is not already contain a repository, then if -*repo* is given it is checked out into the source directory, otherwise a new -repository is initialized in the source directory. - -Second, if a file called `.chezmoi..tmpl` exists, where `` is -one of the supported file formats (e.g. `json`, `toml`, or `yaml`) then a new -configuration file is created using that file as a template. - -Then, if the `--apply` flag is passed, `chezmoi apply` is run. - -Then, if the `--purge` flag is passed, chezmoi will remove the source directory -and its config directory. - -Finally, if the `--purge-binary` is passed, chezmoi will attempt to remove its -own binary. - -#### `--apply` - -Run `chezmoi apply` after checking out the repo and creating the config file. - -#### `--branch` *branch* - -Check out *branch* instead of the default branch. - -#### `--config-path` *path* - -Write the generated config file to *path* instead of the default location. - -#### `--data` *bool* - -Include existing template data when creating the config file. This defaults to -`true`. Set this to `false` to simulate creating the config file with no -existing template data. - -#### `--depth` *depth* - -Clone the repo with depth *depth*. - -#### `--one-shot` - -`--one-shot` is the equivalent of `--apply`, `--depth=1`, `--force`, `--purge`, -and `--purge-binary`. It attempts to install your dotfiles with chezmoi and then -remove all traces of chezmoi from the system. This is useful for setting up -temporary environments (e.g. Docker containers). - -#### `--purge` - -Remove the source and config directories after applying. - -#### `--purge-binary` - -Attempt to remove the chezmoi binary after applying. - -#### `--ssh` - -Guess an SSH repo URL instead of an HTTPS repo. - -#### `init` examples - -```console -$ chezmoi init user -$ chezmoi init user --apply -$ chezmoi init user --apply --purge -$ chezmoi init user/dots -$ chezmoi init gitlab.com/user -``` - ---- - -### `import` *filename* - -Import the source state from an archive file in to a directory in the source -state. This is primarily used to make subdirectories of your home directory -exactly match the contents of a downloaded archive. You will generally always -want to set the `--destination`, `--exact`, and `--remove-destination` flags. - -The supported archive formats are `tar`, `tar.gz`, `tgz`, `tar.bz2`, `tbz2`, and -`zip`. - -#### `--destination` *directory* - -Set the destination (in the source state) where the archive will be imported. - -#### `--exact` - -Set the `exact` attribute on all imported directories. - -#### `-r`, `--remove-destination` - -Remove destination (in the source state) before importing. - -#### `--strip-components` *n* - -Strip *n* leading components from paths. - -#### `import` examples - -```console -$ curl -s -L -o ${TMPDIR}/oh-my-zsh-master.tar.gz https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz -$ mkdir -p $(chezmoi source-path)/dot_oh-my-zsh -$ chezmoi import --strip-components 1 --destination ~/.oh-my-zsh ${TMPDIR}/oh-my-zsh-master.tar.gz -``` - ---- - -### `manage` *targets* - -`manage` is an alias for `add` for symmetry with `unmanage`. - ---- - -### `managed` - -List all managed entries in the destination directory in alphabetical order. - -#### `-i`, `--include` *types* - -Only include entries of type *types*. - -#### `managed` examples - -```console -$ chezmoi managed -$ chezmoi managed --include=files -$ chezmoi managed --include=files,symlinks -$ chezmoi managed -i d -$ chezmoi managed -i d,f -``` - ---- - -### `merge` *target*... - -Perform a three-way merge between the destination state, the target state, and -the source state for each *target*. The merge tool is defined by the -`merge.command` configuration variable, and defaults to `vimdiff`. If multiple -targets are specified the merge tool is invoked separately and sequentially for -each target. If the target state cannot be computed (for example if source is a -template containing errors or an encrypted file that cannot be decrypted) a -two-way merge is performed instead. - -The order of arguments to `merge.command` is set by `merge.args`. Each argument -is interpreted as a template with the variables `.Destination`, `.Source`, and -`.Target` available corresponding to the path of the file in the destination -state, the source state, and the target state respectively. The default value of -`merge.args` is `["{{ .Destination }}", "{{ .Source }}", "{{ .Target }}"]`. If -`merge.args` does not contain any template arguments then `{{ .Destination }}`, -`{{ .Source }}`, and `{{ .Target }}` will be appended automatically. - -#### `merge` examples - -```console -$ chezmoi merge ~/.bashrc -``` - ---- - -### `merge-all` [*target*...] - -Perform a three-way merge for file whose actual state does not match its target -state. The merge is performed with `chezmoi merge`. - -#### `merge-all` examples - -```console -$ chezmoi merge-all -``` - ---- - -### `purge` - -Remove chezmoi's configuration, state, and source directory, but leave the -target state intact. - -#### `-f`, `--force` - -Remove without prompting. - -#### `purge` examples - -```console -$ chezmoi purge -$ chezmoi purge --force -``` - ---- - -### `remove` *targets* - -Remove *targets* from both the source state and the destination directory. - -#### `-f`, `--force` - -Remove without prompting. - ---- - -### `re-add` - -Re-add all modified files in the target state. chezmoi will not overwrite -templates, and all entries that are not files are ignored. - -#### `re-add` examples - -```console -$ chezmoi re-add -``` - ---- - -### `rm` *targets* - -`rm` is an alias for `remove`. - ---- - -### `secret` - -Run a secret manager's CLI, passing any extra arguments to the secret manager's -CLI. This is primarily for verifying chezmoi's integration with your secret -manager. Normally you would use template functions to retrieve secrets. Note -that if you want to pass flags to the secret manager's CLI you will need to -separate them with `--` to prevent chezmoi from interpreting them. - -To get a full list of available commands run: - -```console -$ chezmoi secret help -``` - -#### `secret` examples - -```console -$ chezmoi secret keyring set --service=service --user=user --value=password -$ chezmoi secret keyring get --service=service --user=user -``` - ---- - -### `source-path` [*target*...] - -Print the path to each target's source state. If no targets are specified then -print the source directory. - -#### `source-path` examples - -```console -$ chezmoi source-path -$ chezmoi source-path ~/.bashrc -``` - ---- - -### `state` - -Manipulate the persistent state. - -#### `state` examples - -```console -$ chezmoi state data -$ chezmoi state delete --bucket=bucket --key=key -$ chezmoi state dump -$ chezmoi state get --bucket=bucket --key=key -$ chezmoi state set --bucket=bucket --key=key --value=value -$ chezmoi state reset -``` - ---- - -### `status` - -Print the status of the files and scripts managed by chezmoi in a format similar -to [`git status`](https://git-scm.com/docs/git-status). - -The first column of output indicates the difference between the last state -written by chezmoi and the actual state. The second column indicates the -difference between the actual state and the target state. - -#### `-i`, `--include` *types* - -Only include entries of type *types*. - -#### `status` examples - -```console -$ chezmoi status -``` - ---- - -### `unmanage` *target*... - -`unmanage` is an alias for `forget` for symmetry with `manage`. - ---- - -### `unmanaged` - -List all unmanaged files in the destination directory. - -#### `unmanaged` examples - -```console -$ chezmoi unmanaged -``` - ---- - -### `update` - -Pull changes from the source repo and apply any changes. - -#### `-i`, `--include` *types* - -Only update entries of type *types*. - -#### `update` examples - -```console -$ chezmoi update -``` - ---- - -### `upgrade` - -Upgrade chezmoi by downloading and installing the latest released version. This -will call the GitHub API to determine if there is a new version of chezmoi -available, and if so, download and attempt to install it in the same way as -chezmoi was previously installed. - -If the any of the `$CHEZMOI_GITHUB_ACCESS_TOKEN`, `$GITHUB_ACCESS_TOKEN`, or -`$GITHUB_TOKEN` environment variables are set, then the first value found will -be used to authenticate requests to the GitHub API, otherwise unauthenticated -requests are used which are subject to stricter [rate -limiting](https://developer.github.com/v3/#rate-limiting). Unauthenticated -requests should be sufficient for most cases. - ---- - -### `verify` [*target*...] - -Verify that all *target*s match their target state. chezmoi exits with code 0 -(success) if all targets match their target state, or 1 (failure) otherwise. If -no targets are specified then all targets are checked. - -#### `-i`, `--include` *types* - -Only include entries of type *types*. - -#### `verify` examples - -```console -$ chezmoi verify -$ chezmoi verify ~/.bashrc -``` - ---- - -## Editor configuration - -The `edit` and `edit-config` commands use the editor specified by the `VISUAL` -environment variable, the `$EDITOR` environment variable, or `vi`, whichever is -specified first. - ---- - -## pinentry configuration - -By default, chezmoi will request passwords from the terminal. - -If the `--no-tty` option is passed, then chezmoi will instead read passwords -from the standard input. - -Otherwise, if the configuration variable `pinentry.command` is set then chezmoi -will instead used the given command to read passwords, assuming that it follows -the [Assuan protocol](https://www.gnupg.org/documentation/manuals/assuan.pdf) -like [GnuPG's -pinentry](https://www.gnupg.org/related_software/pinentry/index.html). The -configuration variable `pinentry.args` specifies extra arguments to be passed to -`pinentry.command` and the configuration variable `pinentry.options` specifies -extra options to be set. The default `pinentry.options` is -`["allow-external-password-cache"]`. - -### Example pinentry configuration - -```toml -[pinentry] - command = "pinentry" -``` - ---- - -## Umask configuration - -By default, chezmoi uses your current umask as set by your operating system and -shell. chezmoi only stores crude permissions in its source state, namely in the -`executable` and `private` attributes, corresponding to the umasks of `0o111` -and `0o077` respectively. - -For machine-specific control of umask, set the `umask` configuration variable in -chezmoi's configuration file, for example: - -```toml -umask = 0o22 -``` - ---- - -## Template execution - -chezmoi executes templates using -[`text/template`](https://pkg.go.dev/text/template). The result is treated -differently depending on whether the target is a file or a symlink. - -If target is a file, then: - -* If the result is an empty string, then the file is removed. -* Otherwise, the target file contents are result. - -If the target is a symlink, then: - -* Leading and trailing whitespace are stripped from the result. -* If the result is an empty string, then the symlink is removed. -* Otherwise, the target symlink target is the result. - -chezmoi executes templates using `text/template`'s `missingkey=error` option, -which means that misspelled or missing keys will raise an error. This can be -overridden by setting a list of options in the configuration file, for example: - -```toml -[template] - options = ["missingkey=zero"] -``` - -For a full list of options, see -[`Template.Option`](https://pkg.go.dev/text/template?tab=doc#Template.Option). - ---- - -## Template variables - -chezmoi provides the following automatically-populated variables: - -| Variable | Type | Value | -| -------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `.chezmoi.arch` | `string` | Architecture, e.g. `amd64`, `arm`, etc. as returned by [runtime.GOARCH](https://pkg.go.dev/runtime?tab=doc#pkg-constants). | -| `.chezmoi.args` | `[]string` | The arguments passed to the `chezmoi` command, starting with the program command. | -| `.chezmoi.executable` | `string` | The path to the `chezmoi` executable, if available. | -| `.chezmoi.fqdnHostname` | `string` | The fully-qualified domain name hostname of the machine chezmoi is running on. | -| `.chezmoi.group` | `string` | The group of the user running chezmoi. | -| `.chezmoi.homeDir` | `string` | The home directory of the user running chezmoi. | -| `.chezmoi.hostname` | `string` | The hostname of the machine chezmoi is running on, up to the first `.`. | -| `.chezmoi.kernel` | `string` | Contains information from `/proc/sys/kernel`. Linux only, useful for detecting specific kernels (e.g. Microsoft's WSL kernel). | -| `.chezmoi.os` | `string` | Operating system, e.g. `darwin`, `linux`, etc. as returned by [runtime.GOOS](https://pkg.go.dev/runtime?tab=doc#pkg-constants). | -| `.chezmoi.osRelease` | `string` | The information from `/etc/os-release`, Linux only, run `chezmoi data` to see its output. | -| `.chezmoi.sourceDir` | `string` | The source directory. | -| `.chezmoi.sourceFile` | `string` | The path of the template relative to the source directory. | -| `.chezmoi.username` | `string` | The username of the user running chezmoi. | -| `.chezmoi.version.builtBy` | `string` | The program that built the `chezmoi` executable, if set. | -| `.chezmoi.version.commit` | `string` | The git commit at which the `chezmoi` executable was built, if set. | -| `.chezmoi.version.date` | `string` | The timestamp at which the `chezmoi` executable was built, if set. | -| `.chezmoi.version.version` | `string` | The version of chezmoi. | - -Additional variables can be defined in the config file in the `data` section. -Variable names must consist of a letter and be followed by zero or more letters -and/or digits. - ---- - -## Template functions - -All standard [`text/template`](https://pkg.go.dev/text/template) and [text -template functions from `sprig`](http://masterminds.github.io/sprig/) are -included. chezmoi provides some additional functions. - ---- - -### `bitwarden` [*arg*...] - -`bitwarden` returns structured data retrieved from -[Bitwarden](https://bitwarden.com) using the [Bitwarden -CLI](https://github.com/bitwarden/cli) (`bw`). *arg*s are passed to `bw get` -unchanged and the output from `bw get` is parsed as JSON. The output from `bw -get` is cached so calling `bitwarden` multiple times with the same arguments -will only invoke `bw` once. - -#### `bitwarden` examples - -``` -username = {{ (bitwarden "item" "").login.username }} -password = {{ (bitwarden "item" "").login.password }} -``` - ---- - -### `bitwardenAttachment` *filename* *itemid* - -`bitwardenAttachment` returns a document from -[Bitwarden](https://bitwarden.com/) using the [Bitwarden -CLI](https://bitwarden.com/help/article/cli/) (`bw`). *filename* and *itemid* is -passed to `bw get attachment --itemid ` and the output from -`bw` is returned. The output from `bw` is cached so calling -`bitwardenAttachment` multiple times with the same *filename* and *itemid* will -only invoke `bw` once. - -#### `bitwardenAttachment` examples - -``` -{{- (bitwardenAttachment "" "") -}} -``` - ---- - -### `bitwardenFields` [*arg*...] - -`bitwardenFields` returns structured data retrieved from -[Bitwarden](https://bitwarden.com) using the [Bitwarden -CLI](https://github.com/bitwarden/cli) (`bw`). *arg*s are passed to `bw get` -unchanged, the output from `bw get` is parsed as JSON, and elements of `fields` -are returned as a map indexed by each field's `name`. For example, given the -output from `bw get`: - -```json -{ - "object": "item", - "id": "bf22e4b4-ae4a-4d1c-8c98-ac620004b628", - "organizationId": null, - "folderId": null, - "type": 1, - "name": "example.com", - "notes": null, - "favorite": false, - "fields": [ - { - "name": "text", - "value": "text-value", - "type": 0 - }, - { - "name": "hidden", - "value": "hidden-value", - "type": 1 - } - ], - "login": { - "username": "username-value", - "password": "password-value", - "totp": null, - "passwordRevisionDate": null - }, - "collectionIds": [], - "revisionDate": "2020-10-28T00:21:02.690Z" -} -``` - -the return value will be the map - -```json -{ - "hidden": { - "name": "hidden", - "type": 1, - "value": "hidden-value" - }, - "token": { - "name": "token", - "type": 0, - "value": "token-value" - } -} -``` - -The output from `bw get` is cached so calling `bitwarden` multiple times with -the same arguments will only invoke `bw get` once. - -#### `bitwardenFields` examples - -``` -{{ (bitwardenFields "item" "").token.value }} -``` - ---- - -### `decrypt` *ciphertext* - -`decrypt` decrypts *ciphertext* using chezmoi's configured encryption method. - -#### `decrypt` examples - -``` -{{ joinPath .chezmoi.sourceDir ".ignored-encrypted-file.age" | include | decrypt }} -``` - ---- - -### `encrypt` *plaintext* - -`encrypt` encrypts *plaintext* using chezmoi's configured encryption method. - ---- - -### `fromYaml` *yamltext* - -`fromYaml` returns the parsed value of *yamltext*. - -#### `fromYaml` examples - -``` -{{ (fromYaml "key1: value\nkey2: value").key2 }} -``` - ---- - -### `gitHubKeys` *user* - -`gitHubKeys` returns *user*'s public SSH keys from GitHub using the GitHub API. -The returned value is a slice of structs with `.ID` and `.Key` fields. - -**WARNING** if you use this function to populate your `~/.ssh/authorized_keys` -file then you potentially open SSH access to anyone who is able to modify or add -to your GitHub public SSH keys, possibly including certain GitHub employees. You -should not use this function on publicly-accessible machines and should always -verify that no unwanted keys have been added, for example by using the `-v` / -`--verbose` option when running `chezmoi apply` or `chezmoi update`. - -By default, an anonymous GitHub API request will be made, which is subject to -[GitHub's rate -limits](https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting) -(currently 60 requests per hour per source IP address). If any of the -environment variables `$CHEZMOI_GITHUB_ACCESS_TOKEN`, `$GITHUB_ACCESS_TOKEN`, or -`$GITHUB_TOKEN` are found, then the first one found will be used to authenticate -the GitHub API request, with a higher rate limit (currently 5,000 requests per -hour per user). - -In practice, GitHub API rate limits are high enough that you should rarely need -to set a token, unless you are sharing a source IP address with many other -GitHub users. If needed, the GitHub documentation describes how to [create a -personal access -token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). - -#### `gitHubKeys` examples - -``` -{{ range (gitHubKeys "user") }} -{{- .Key }} -{{ end }} -``` - ---- - -### `gitHubLatestRelease` *user-repo* - -`gitHubLatestRelease` calls the GitHub API to retrieve the latest release about the given -*user-repo*, returning structured data as defined by the [GitHub Go API -bindings](https://pkg.go.dev/github.com/google/go-github/v40/github#RepositoryRelease). - -Calls to `gitHubLatestRelease` are cached so calling `gitHubLatestRelease` with the same -*user-repo* will only result in one call to the GitHub API. - - -`gitHubLatestRelease` uses the same API request mechanism as `gitHubKeys`. - -#### `gitHubLatestRelease` examples - -``` -{{ (gitHubLatestRelease "docker/compose").TagName }} -``` - ---- - -### `gopass` *gopass-name* - -`gopass` returns passwords stored in [gopass](https://www.gopass.pw/) using the -gopass CLI (`gopass`). *gopass-name* is passed to `gopass show --password -` and the first line of the output of `gopass` is returned with the -trailing newline stripped. The output from `gopass` is cached so calling -`gopass` multiple times with the same *gopass-name* will only invoke `gopass` -once. - -#### `gopass` examples - -``` -{{ gopass "" }} -``` - ---- - -### `gopassRaw` *gopass-name* - -`gopass` returns raw passwords stored in [gopass](https://www.gopass.pw/) using -the gopass CLI (`gopass`). *gopass-name* is passed to `gopass show --noparsing -` and the output is returned. The output from `gopassRaw` is cached -so calling `gopassRaw` multiple times with the same *gopass-name* will only -invoke `gopass` once. - ---- - -### `include` *filename* - -`include` returns the literal contents of the file named `*filename*`. Relative -paths are interpreted relative to the source directory. - ---- - -### `ioreg` - -On macOS, `ioreg` returns the structured output of the `ioreg -a -l` command, -which includes detailed information about the I/O Kit registry. - -On non-macOS operating systems, `ioreg` returns `nil`. - -The output from `ioreg` is cached so multiple calls to the `ioreg` function will -only execute the `ioreg -a -l` command once. - -#### `ioreg` examples - -``` -{{ if (eq .chezmoi.os "darwin") }} -{{ $serialNumber := index ioreg "IORegistryEntryChildren" 0 "IOPlatformSerialNumber" }} -{{ end }} -``` - ---- - -### `joinPath` *element*... - -`joinPath` joins any number of path elements into a single path, separating them -with the OS-specific path separator. Empty elements are ignored. The result is -cleaned. If the argument list is empty or all its elements are empty, `joinPath` -returns an empty string. On Windows, the result will only be a UNC path if the -first non-empty element is a UNC path. - -#### `joinPath` examples - -``` -{{ joinPath .chezmoi.homeDir ".zshrc" }} -``` - ---- - -### `keepassxc` *entry* - -`keepassxc` returns structured data retrieved from a -[KeePassXC](https://keepassxc.org/) database using the KeePassXC CLI -(`keepassxc-cli`). The database is configured by setting `keepassxc.database` in -the configuration file. *database* and *entry* are passed to `keepassxc-cli -show`. You will be prompted for the database password the first time -`keepassxc-cli` is run, and the password is cached, in plain text, in memory -until chezmoi terminates. The output from `keepassxc-cli` is parsed into -key-value pairs and cached so calling `keepassxc` multiple times with the same -*entry* will only invoke `keepassxc-cli` once. - -#### `keepassxc` examples - -``` -username = {{ (keepassxc "example.com").UserName }} -password = {{ (keepassxc "example.com").Password }} -``` - ---- - -### `keepassxcAttribute` *entry* *attribute* - -`keepassxcAttribute` returns the attribute *attribute* of *entry* using -`keepassxc-cli`, with any leading or trailing whitespace removed. It behaves -identically to the `keepassxc` function in terms of configuration, password -prompting, password storage, and result caching. - -#### `keepassxcAttribute` examples - -``` -{{ keepassxcAttribute "SSH Key" "private-key" }} -``` - ---- - -### `keyring` *service* *user* - -`keyring` retrieves the value associated with *service* and *user* from the -user's keyring. - -| OS | Keyring | -| ------- | --------------------------- | -| macOS | Keychain | -| Linux | GNOME Keyring | -| Windows | Windows Credentials Manager | - -#### `keyring` examples - -``` -[github] - user = {{ .github.user | quote }} - token = {{ keyring "github" .github.user | quote }} -``` - ---- - -### `lastpass` *id* - -`lastpass` returns structured data from [LastPass](https://lastpass.com) using -the [LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html) -(`lpass`). *id* is passed to `lpass show --json ` and the output from -`lpass` is parsed as JSON. In addition, the `note` field, if present, is further -parsed as colon-separated key-value pairs. The structured data is an array so -typically the `index` function is used to extract the first item. The output -from `lastpass` is cached so calling `lastpass` multiple times with the same -*id* will only invoke `lpass` once. - -#### `lastpass` examples - -``` -githubPassword = {{ (index (lastpass "GitHub") 0).password | quote }} -{{ (index (lastpass "SSH") 0).note.privateKey }} -``` - ---- - -### `lastpassRaw` *id* - -`lastpassRaw` returns structured data from [LastPass](https://lastpass.com) -using the [LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html) -(`lpass`). It behaves identically to the `lastpass` function, except that no -further parsing is done on the `note` field. - -#### `lastpassRaw` examples - -``` -{{ (index (lastpassRaw "SSH Private Key") 0).note }} -``` - ---- - -### `lookPath` *file* - -`lookPath` searches for an executable named *file* in the directories named by -the `PATH` environment variable. If file contains a slash, it is tried directly -and the `PATH` is not consulted. The result may be an absolute path or a path -relative to the current directory. If *file* is not found, `lookPath` returns an -empty string. - -`lookPath` is not hermetic: its return value depends on the state of the -environment and the filesystem at the moment the template is executed. Exercise -caution when using it in your templates. - -#### `lookPath` examples - -``` -{{ if lookPath "diff-so-fancy" }} -# diff-so-fancy is in $PATH -{{ end }} -``` - ---- - -### `mozillaInstallHash` *path* - -`mozillaInstallHash` returns the Mozilla install hash for *path*. This is a -convenience function to assist the management of Firefox profiles. - ---- - -### `onepassword` *uuid* [*vault-uuid* [*account-name*]] - -`onepassword` returns structured data from [1Password](https://1password.com/) -using the [1Password -CLI](https://support.1password.com/command-line-getting-started/) (`op`). *uuid* -is passed to `op get item ` and the output from `op` is parsed as JSON. -The output from `op` is cached so calling `onepassword` multiple times with the -same *uuid* will only invoke `op` once. If the optional *vault-uuid* is -supplied, it will be passed along to the `op get` call, which can significantly -improve performance. If the optional *account-name* is supplied, it will be -passed along to the `op get` call, which will help it look in the right account, -in case you have multiple accounts (eg. personal and work accounts). - -#### `onepassword` examples - -``` -{{ (onepassword "").details.password }} -{{ (onepassword "" "").details.password }} -{{ (onepassword "" "" "").details.password }} -{{ (onepassword "" "" "").details.password }} -``` - ---- - -### `onepasswordDocument` *uuid* [*vault-uuid* [*account-name*]] - -`onepassword` returns a document from [1Password](https://1password.com/) using -the [1Password CLI](https://support.1password.com/command-line-getting-started/) -(`op`). *uuid* is passed to `op get document ` and the output from `op` is -returned. The output from `op` is cached so calling `onepasswordDocument` -multiple times with the same *uuid* will only invoke `op` once. If the optional -*vault-uuid* is supplied, it will be passed along to the `op get` call, which -can significantly improve performance. If the optional *account-name* is -supplied, it will be passed along to the `op get` call, which will help it look -in the right account, in case you have multiple accounts (eg. personal and work -accounts). - -#### `onepasswordDocument` examples - -``` -{{- onepasswordDocument "" -}} -{{- onepasswordDocument "" "" -}} -{{- onepasswordDocument "" "" "" -}} -{{- onepasswordDocument "" "" "" -}} -``` - ---- - -### `onepasswordDetailsFields` *uuid* [*vault-uuid* [*account-name*]] - -`onepasswordDetailsFields` returns structured data from -[1Password](https://1password.com/) using the [1Password -CLI](https://support.1password.com/command-line-getting-started/) (`op`). *uuid* -is passed to `op get item `, the output from `op` is parsed as JSON, and -elements of `details.fields` are returned as a map indexed by each field's -`designation`. For example, give the output from `op`: - -```json -{ - "uuid": "", - "details": { - "fields": [ - { - "designation": "username", - "name": "username", - "type": "T", - "value": "exampleuser" - }, - { - "designation": "password", - "name": "password", - "type": "P", - "value": "examplepassword" - } - ] - } -} -``` - -the return value will be the map: - -```json -{ - "username": { - "designation": "username", - "name": "username", - "type": "T", - "value": "exampleuser" - }, - "password": { - "designation": "password", - "name": "password", - "type": "P", - "value": "examplepassword" - } -} -``` - -The output from `op` is cached so calling `onepasswordDetailsFields` multiple -times with the same *uuid* will only invoke `op` once. If the optional -*vault-uuid* is supplied, it will be passed along to the `op get` call, which -can significantly improve performance. If the optional *account-name* is -supplied, it will be passed along to the `op get` call, which will help it look -in the right account, in case you have multiple accounts (eg. personal and work -accounts). - -#### `onepasswordDetailsFields` examples - -``` -{{ (onepasswordDetailsFields "").password.value }} -{{ (onepasswordDetailsFields "" "").password.value }} -{{ (onepasswordDetailsFields "" "" "").password.value }} -{{ (onepasswordDetailsFields "" "" "").password.value }} -``` - ---- - -### `onepasswordItemFields` *uuid* [*vault-uuid* [*account-name*]] - -`onepasswordItemFields` returns structured data from -[1Password](https://1password.com/) using the [1Password -CLI](https://support.1password.com/command-line-getting-started/) (`op`). *uuid* -is passed to `op get item `, the output from `op` is parsed as JSON, and -each element of `details.sections` are iterated over and any `fields` are -returned as a map indexed by each field's `n`. For example, give the output from -`op`: - -```json -{ - "uuid": "", - "details": { - "sections": [ - { - "name": "linked items", - "title": "Related Items" - }, - { - "fields": [ - { - "k": "string", - "n": "D4328E0846D2461E8E455D7A07B93397", - "t": "exampleLabel", - "v": "exampleValue" - } - ], - "name": "Section_20E0BD380789477D8904F830BFE8A121", - "title": "" - } - ] - }, -} -``` - -the return value will be the map: - -```json -{ - "exampleLabel": { - "k": "string", - "n": "D4328E0846D2461E8E455D7A07B93397", - "t": "exampleLabel", - "v": "exampleValue" - } -} -``` - -The result of - -``` -{{ (onepasswordItemFields "abcdefghijklmnopqrstuvwxyz").exampleLabel.v }} -``` - -is equivalent to calling - -```console -$ op get item abcdefghijklmnopqrstuvwxyz --fields exampleLabel -``` - ---- - -### `output` *name* [*arg*...] - -`output` returns the output of executing the command *name* with *arg*s. If -executing the command returns an error then template execution exits with an -error. The execution occurs every time that the template is executed. It is the -user's responsibility to ensure that executing the command is both idempotent -and fast. - -#### `output` examples - -``` -current-context: {{ output "kubectl" "config" "current-context" | trim }} -``` - ---- - -### `pass` *pass-name* - -`pass` returns passwords stored in [pass](https://www.passwordstore.org/) using -the pass CLI (`pass`). *pass-name* is passed to `pass show ` and the -first line of the output of `pass` is returned with the trailing newline -stripped. The output from `pass` is cached so calling `pass` multiple times with -the same *pass-name* will only invoke `pass` once. - -#### `pass` examples - -``` -{{ pass "" }} -``` - ---- - -### `passRaw` *pass-name* - -`passRaw` returns passwords stored in [pass](https://www.passwordstore.org/) -using the pass CLI (`pass`). *pass-name* is passed to `pass show ` -and the output is returned. The output from `pass` is cached so calling -`passRaw` multiple times with the same *pass-name* will only invoke `pass` once. - ---- - -### `promptBool` *prompt* [*default*] - -`promptBool` prompts the user with *prompt* and returns the user's response -interpreted as a boolean. If *default* is passed the user's response is empty -then it returns *default*. It is only available when generating the initial -config file. The user's response is interpreted as follows (case insensitive): - -| Response | Result | -| ----------------------- | ------- | -| 1, on, t, true, y, yes | `true` | -| 0, off, f, false, n, no | `false` | - ---- - -### `promptInt` *prompt* [*default*] - -`promptInt` prompts the user with *prompt* and returns the user's response -interpreted as an integer. If *default* is passed and the user's response is -empty then it returns *default*. It is only available when generating the -initial config file. - ---- - -### `promptString` *prompt* [*default*] - -`promptString` prompts the user with *prompt* and returns the user's response -with all leading and trailing spaces stripped. If *default* is passed and the -user's response is empty then it returns *default*. It is only available when -generating the initial config file. - -#### `promptString` examples - -``` -{{ $email := promptString "email" -}} -[data] - email = {{ $email | quote }} -``` - ---- - -### `secret` [*arg*...] - -`secret` returns the output of the generic secret command defined by the -`secret.command` configuration variable with *arg*s with leading and trailing -whitespace removed. The output is cached so multiple calls to `secret` with the -same *arg*s will only invoke the generic secret command once. - ---- - -### `secretJSON` [*arg*...] - -`secretJSON` returns structured data from the generic secret command defined by -the `secret.command` configuration variable with *arg*s. The output is parsed as -JSON. The output is cached so multiple calls to `secret` with the same *args* -will only invoke the generic secret command once. - ---- - -### `stat` *name* - -`stat` runs `stat(2)` on *name*. If *name* exists it returns structured data. If -*name* does not exist then it returns a false value. If `stat(2)` returns any -other error then it raises an error. The structured value returned if *name* -exists contains the fields `name`, `size`, `mode`, `perm`, `modTime`, and -`isDir`. - -`stat` is not hermetic: its return value depends on the state of the filesystem -at the moment the template is executed. Exercise caution when using it in your -templates. - -#### `stat` examples - -``` -{{ if stat (joinPath .chezmoi.homeDir ".pyenv") }} -# ~/.pyenv exists -{{ end }} -``` - ---- - -### `stdinIsATTY` - -`stdinIsATTY` returns `true` if chezmoi's standard input is a TTY. It is only -available when generating the initial config file. It is primarily useful for -determining whether `prompt*` functions should be called or default values be -used. - -#### `stdinIsATTY` examples - -``` -{{ $email := "" }} -{{ if stdinIsATTY }} -{{ $email = promptString "email" }} -{{ else }} -{{ $email = "user@example.com" }} -{{ end }} -``` - ---- - -### `toYaml` *value* - -`toYaml` returns the YAML representation of *value*. - -#### `toYaml` examples - -``` -{{ dict "key" "value" | toYaml }} -``` - ---- - -### `vault` *key* - -`vault` returns structured data from [Vault](https://www.vaultproject.io/) using -the [Vault CLI](https://www.vaultproject.io/docs/commands/) (`vault`). *key* is -passed to `vault kv get -format=json ` and the output from `vault` is -parsed as JSON. The output from `vault` is cached so calling `vault` multiple -times with the same *key* will only invoke `vault` once. - -#### `vault` examples - -``` -{{ (vault "").data.data.password }} -``` - ---- - -### `writeToStdout` *string*... - -`writeToStdout` writes each *string* to stdout. It is only available when -generating the initial config file. - -#### `writeToStdout` examples - -``` -{{- writeToStdout "Hello, world\n" -}} -``` diff --git a/docs/RELATED.md b/docs/RELATED.md deleted file mode 100644 index 73d3d50aeb5..00000000000 --- a/docs/RELATED.md +++ /dev/null @@ -1,42 +0,0 @@ -# chezmoi related software - - -* [`github.com/alker0/chezmoi.vim`](#githubcomalker0chezmoivim) -* [`github.com/hussainweb/ansible-role-chezmoi`](#githubcomhussainwebansible-role-chezmoi) -* [`github.com/tcaxle/drapeau`](#githubcomtcaxledrapeau) -* [`github.com/tuh8888/chezmoi.el`](#githubcomtuh8888chezmoiel) -* [`github.com/Lilja/vim-chezmoi`](#githubcomliljavim-chezmoi) - ---- - -## [`github.com/alker0/chezmoi.vim`](https://github.com/alker0/chezmoi.vim) - -Intelligent VIM syntax highlighting when editing files in your source directory. -Works with both `chezmoi edit` and editing files directly. - ---- - -## [`github.com/hussainweb/ansible-role-chezmoi`](https://github.com/hussainweb/ansible-role-chezmoi) - -Installs chezmoi on Ubuntu and Debian servers. - ---- - -## [`github.com/tcaxle/drapeau`](https://github.com/tcaxle/drapeau) - -An add-on to synchronise your colorschemes across systems and allow easy -colorscheme switching using chezmoi templates. - ---- - -## [`github.com/tuh8888/chezmoi.el`](https://github.com/tuh8888/chezmoi.el) - -Convenience functions for interacting with chezmoi in Emacs. - ---- - -## [`github.com/Lilja/vim-chezmoi`](https://github.com/Lilja/vim-chezmoi) - -A plugin for VIM to apply the dotfile you are editing on `:w`. - ---- diff --git a/docs/docs.go b/docs/docs.go deleted file mode 100644 index d25784f4cc8..00000000000 --- a/docs/docs.go +++ /dev/null @@ -1,8 +0,0 @@ -// Package docs contains chezmoi's documentation. -package docs - -import "embed" - -// FS contains all docs. -//go:embed *.md -var FS embed.FS diff --git a/go.mod b/go.mod index 63e799d2882..b9f7a448a01 100644 --- a/go.mod +++ b/go.mod @@ -1,65 +1,170 @@ module github.com/twpayne/chezmoi/v2 -go 1.16 +go 1.21.4 + +toolchain go1.22.0 require ( - filippo.io/age v1.0.0 - github.com/Masterminds/sprig/v3 v3.2.2 - github.com/Microsoft/go-winio v0.5.1 // indirect - github.com/ProtonMail/go-crypto v0.0.0-20211112122917-428f8eabeeb3 // indirect - github.com/alecthomas/chroma v0.9.4 // indirect - github.com/bmatcuk/doublestar/v4 v4.0.2 - github.com/bradenhilton/mozillainstallhash v1.0.0 - github.com/charmbracelet/glamour v0.3.0 - github.com/coreos/go-semver v0.3.0 - github.com/danieljoos/wincred v1.1.2 // indirect - github.com/go-git/go-git/v5 v5.4.2 - github.com/godbus/dbus/v5 v5.0.6 // indirect - github.com/google/go-github/v40 v40.0.0 - github.com/google/gops v0.3.22 + filippo.io/age v1.2.0 + github.com/1password/onepassword-sdk-go v0.1.0-beta.10 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.1.0 + github.com/Masterminds/sprig/v3 v3.2.3 + github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 + github.com/Shopify/ejson v1.5.2 + github.com/alecthomas/assert/v2 v2.10.0 + github.com/aws/aws-sdk-go-v2 v1.30.3 + github.com/aws/aws-sdk-go-v2/config v1.27.26 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.3 + github.com/bmatcuk/doublestar/v4 v4.6.1 + github.com/bradenhilton/mozillainstallhash v1.0.1 + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.26.6 + github.com/charmbracelet/glamour v0.7.0 + github.com/coreos/go-semver v0.3.1 + github.com/fsnotify/fsnotify v1.7.0 + github.com/go-git/go-git/v5 v5.12.0 + github.com/google/go-github/v63 v63.0.0 github.com/google/renameio/v2 v2.0.0 - github.com/google/uuid v1.3.0 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 - github.com/huandu/xstrings v1.3.2 // indirect - github.com/kevinburke/ssh_config v1.1.0 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect - github.com/microcosm-cc/bluemonday v1.0.16 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/mapstructure v1.4.2 + github.com/itchyny/gojq v0.12.16 + github.com/klauspost/compress v1.17.9 + github.com/mitchellh/copystructure v1.2.0 + github.com/mitchellh/mapstructure v1.5.0 github.com/muesli/combinator v0.3.0 + github.com/muesli/termenv v0.15.2 + github.com/pelletier/go-toml/v2 v2.2.2 + github.com/rogpeppe/go-internal v1.12.0 + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 + github.com/spf13/cobra v1.8.1 + github.com/spf13/pflag v1.0.5 + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a + github.com/twpayne/go-pinentry/v4 v4.0.0 + github.com/twpayne/go-shell v0.4.0 + github.com/twpayne/go-vfs/v5 v5.0.4 + github.com/twpayne/go-xdg/v6 v6.1.3 + github.com/ulikunitz/xz v0.5.12 + github.com/zalando/go-keyring v0.2.5 + github.com/zricethezav/gitleaks/v8 v8.18.4 + go.etcd.io/bbolt v1.3.10 + golang.org/x/crypto v0.25.0 + golang.org/x/crypto/x509roots/fallback v0.0.0-20240709155400-d66d9c31b4ae + golang.org/x/oauth2 v0.21.0 + golang.org/x/sync v0.7.0 + golang.org/x/sys v0.22.0 + golang.org/x/term v0.22.0 + gopkg.in/ini.v1 v1.67.0 + gopkg.in/yaml.v3 v3.0.1 + howett.net/plist v1.0.1 + mvdan.cc/sh/v3 v3.8.0 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect + github.com/BobuSumisu/aho-corasick v1.0.3 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.0.0 // indirect + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/alecthomas/repr v0.4.0 // indirect + github.com/alessio/shellescape v1.4.2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.26 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.22.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect + github.com/aws/smithy-go v1.20.3 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/bradenhilton/cityhash v1.0.0 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/lipgloss v0.12.1 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect + github.com/charmbracelet/x/input v0.1.2 // indirect + github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.2 // indirect + github.com/cloudflare/circl v1.3.9 // indirect + github.com/creack/pty/v2 v2.0.0-20231209135443-03db72c7b76c // indirect + github.com/cyphar/filepath-securejoin v0.3.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/dlclark/regexp2 v1.11.2 // indirect + github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/extism/go-sdk v1.3.0 // indirect + github.com/fatih/semgroup v1.2.0 // indirect + github.com/gitleaks/go-gitdiff v0.9.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/btree v1.1.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/h2non/filetype v1.1.3 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/imdario/mergo v0.3.16 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.9.0 // indirect - github.com/pelletier/go-toml v1.9.4 + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect - github.com/rogpeppe/go-internal v1.8.0 - github.com/rs/zerolog v1.26.0 - github.com/sergi/go-diff v1.1.0 - github.com/shopspring/decimal v1.3.1 // indirect - github.com/spf13/afero v1.6.0 - github.com/spf13/cobra v1.2.1 - github.com/spf13/viper v1.9.0 - github.com/stretchr/objx v0.3.0 // indirect - github.com/stretchr/testify v1.7.0 - github.com/twpayne/go-pinentry v0.0.2 - github.com/twpayne/go-shell v0.3.1 - github.com/twpayne/go-vfs/v4 v4.1.0 - github.com/twpayne/go-xdg/v6 v6.0.0 - github.com/xanzy/ssh-agent v0.3.1 // indirect - github.com/yuin/goldmark v1.4.4 // indirect - github.com/zalando/go-keyring v0.1.1 - go.etcd.io/bbolt v1.3.6 - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.7.0 - golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 // indirect - golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect - golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 - golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1 - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 - golang.org/x/text v0.3.7 // indirect - gopkg.in/ini.v1 v1.64.0 // indirect - gopkg.in/yaml.v2 v2.4.0 - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b - howett.net/plist v0.0.0-20201203080718-1454fab16a06 + github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rs/zerolog v1.33.0 // indirect + github.com/sagikazarmark/locafero v0.6.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/skeema/knownhosts v1.2.2 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tetratelabs/wazero v1.7.3 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.4 // indirect + github.com/yuin/goldmark-emoji v1.0.3 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/tools v0.23.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect ) -exclude github.com/sergi/go-diff v1.2.0 // Produces incorrect diffs +exclude github.com/microcosm-cc/bluemonday v1.0.26 // https://github.com/microcosm-cc/bluemonday/pull/195 + +// github.com/Netflix/go-expect is unmaintained. Use a temporary fork. +replace github.com/Netflix/go-expect => github.com/twpayne/go-expect v0.0.1 diff --git a/go.sum b/go.sum index 6e9652727e4..f26e10db143 100644 --- a/go.sum +++ b/go.sum @@ -1,1067 +1,622 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= -cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= -cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= -cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= -cloud.google.com/go v0.93.3 h1:wPBktZFzYBcCZVARvwVKqH1uEj+aLXofJEtrb4oOsio= -cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0 h1:PQcPefKFdaIzjQFbiyOgAqyx8q5djaE7x9Sqe712DPA= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0 h1:/May9ojXjRkPBNVrq+oWLqmWCkr4OU5uRY29bu0mRyQ= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/firestore v1.6.0 h1:dMIWvm+3O0E3DM7kcZPH0FBQ94Xg/OMkdTNDaY9itbI= -cloud.google.com/go/firestore v1.6.0/go.mod h1:afJwI0vaXwAG54kI7A//lP/lSPDkQORQuMkv56TxEPU= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1 h1:ukjixP1wl0LpnZ6LWtZJ0mX5tBmjp1f8Sqer8Z2OMUU= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0 h1:STgFzyU5/8miMl0//zKh2aQeTyeaUH3WN9bSUiJ09bA= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUuKccphEpTJnWhS2jkQyMt6Y7pJCD7fY= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -filippo.io/age v1.0.0 h1:V6q14n0mqYU3qKFkZ6oOaF9oXneOviS3ubXsSVBRSzc= -filippo.io/age v1.0.0/go.mod h1:PaX+Si/Sd5G8LgfCwldsSba3H1DDQZhIhFGkhbHaBq8= -filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU= -filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= +c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= +cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM= +cloud.google.com/go v0.112.1/go.mod h1:+Vbu+Y1UU+I1rjmzeMOb/8RfkKJK2Gyxi1X6jJCZLo4= +cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= +cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40= +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/firestore v1.15.0 h1:/k8ppuWOtNuDHt2tsRV42yI21uaGnKDEQnRFeBpbFF8= +cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk= +cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI= +cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= +cloud.google.com/go/longrunning v0.5.5 h1:GOE6pZFdSrTb4KAiKnXsJBtlE6mEyaW44oKyMILWnOg= +cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s= +cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w= +cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/age v1.2.0 h1:vRDp7pUMaAJzXNIWJVAZnEf/Dyi4Vu4wI8S1LBzufhE= +filippo.io/age v1.2.0/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/1password/onepassword-sdk-go v0.1.0-beta.10 h1:vYm15kP/HMWdJaScgWFUu0zxl5QeRgUAwg322VabV54= +github.com/1password/onepassword-sdk-go v0.1.0-beta.10/go.mod h1:FnJzZHo0kfR7U4M3f9xRbKIAn+sR9pn1Ssu3zGDcMpM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0 h1:1nGuui+4POelzDwI7RG56yfQJHCnKvwfMoU7VsEp+Zg= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0/go.mod h1:99EvauvlcJ1U06amZiksfYz/3aFGyIhWGHVyiZXtBAI= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.1 h1:Xy/qV1DyOhhqsU/z0PyFMJfYCxnzna+vBEUtFW0ksQo= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.1/go.mod h1:oib6iWdC+sILvNUoJbbBn3xv7TXow7mEp/WRcsYvmow= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.1.0 h1:h4Zxgmi9oyZL2l8jeg1iRTqPloHktywWcu0nlJmo1tA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.1.0/go.mod h1:LgLGXawqSreJz135Elog0ywTJDsm0Hz2k+N+6ZK35u8= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.1 h1:9fXQS/0TtQmKXp8SureKouF+idbQvp7cPUxykiohnBs= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.1/go.mod h1:f+OaoSg0VQYPMqB0Jp2D54j1VHzITYcJaCNwV+k00ts= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8a+4nPE9g= +github.com/BobuSumisu/aho-corasick v1.0.3/go.mod h1:hm4jLcvZKI2vRF2WDU1N4p/jpWtpOzp3nLmi9AzX/XE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= -github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= -github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= -github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= -github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= -github.com/Microsoft/go-winio v0.5.1 h1:aPJp2QD7OOrhO5tQXqQoGSJc+DjDtWTGLOmNyAm6FgY= -github.com/Microsoft/go-winio v0.5.1/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= -github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= -github.com/ProtonMail/go-crypto v0.0.0-20211112122917-428f8eabeeb3 h1:XcF0cTDJeiuZ5NU8w7WUDge0HRwwNRmxj/GGk6KSA6g= -github.com/ProtonMail/go-crypto v0.0.0-20211112122917-428f8eabeeb3/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= -github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= -github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= -github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk= -github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= -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.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM= -github.com/alecthomas/chroma v0.9.4 h1:YL7sOAE3p8HS96T9km7RgvmsZIctqbK1qJ0b7hzed44= -github.com/alecthomas/chroma v0.9.4/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= -github.com/alecthomas/kong v0.2.4 h1:Y0ZBCHAvHhTHw7FFJ2FzCAAG4pkbTgA45nc7BpMhDNk= -github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= -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/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA= -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 h1:8GUt8eRujhVEGZFFEjBj46YV4rDjvGrNxb0KMWYkL2I= -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-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= -github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= +github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/Shopify/ejson v1.5.2 h1:sXUlmNd5MFHfxIvchQqkbksYmKmHb05coSYhMpWpUNs= +github.com/Shopify/ejson v1.5.2/go.mod h1:bVvQ3MaBCfMOkIp1rWZcot3TruYXCc7qUUbI1tjs/YM= +github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= +github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= +github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= 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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= +github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= +github.com/aws/aws-sdk-go-v2/config v1.27.26 h1:T1kAefbKuNum/AbShMsZEro6eRkeOT8YILfE9wyjAYQ= +github.com/aws/aws-sdk-go-v2/config v1.27.26/go.mod h1:ivWHkAWFrw/nxty5Fku7soTIVdqZaZ7dw+tc5iGW3GA= +github.com/aws/aws-sdk-go-v2/credentials v1.17.26 h1:tsm8g/nJxi8+/7XyJJcP2dLrnK/5rkFp6+i2nhmz5fk= +github.com/aws/aws-sdk-go-v2/credentials v1.17.26/go.mod h1:3vAM49zkIa3q8WT6o9Ve5Z0vdByDMwmdScO0zvThTgI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.3 h1:ilavrucVBQHYnMjD2KmZQDCU1fuluQb0l9zRigGNVEc= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.3/go.mod h1:TKKN7IQoM7uTnyuFm9bm9cw5P//ZYTl4m3htBWQ1G/c= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.3 h1:Fv1vD2L65Jnp5QRsdiM64JvUM4Xe+E0JyVsRQKv6IeA= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.3/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ= +github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= +github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 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/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.4 h1:w/jqZtC9YD4DS/Vp9GhWfWcCpuAL58oTnLoI8vE9YHU= -github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= -github.com/bmatcuk/doublestar/v4 v4.0.2 h1:X0krlUVAVmtr2cRoTqR8aDMrDqnB36ht8wpWTiQ3jsA= -github.com/bmatcuk/doublestar/v4 v4.0.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bradenhilton/cityhash v1.0.0 h1:1QauDCwfxwIGwO2jBTJdEBqXgfCusAgQOSgdl4RsTMI= github.com/bradenhilton/cityhash v1.0.0/go.mod h1:Wmb8yW1egA9ulrsRX4mxfYx5zq4nBWOCZ+j63oK6uz8= -github.com/bradenhilton/mozillainstallhash v1.0.0 h1:QL9byVGb4FrVOI7MubnME3uPNj5R78tqYQPlxuBmXMw= -github.com/bradenhilton/mozillainstallhash v1.0.0/go.mod h1:yVD0OX1izZHYl1lBm2UDojyE/k0xIqKJK78k+tdWV+k= -github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/charmbracelet/glamour v0.3.0 h1:3H+ZrKlSg8s+WU6V7eF2eRVYt8lCueffbi7r2+ffGkc= -github.com/charmbracelet/glamour v0.3.0/go.mod h1:TzF0koPZhqq0YVBNL100cPHznAAjVj7fksX2RInwjGw= -github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed h1:OZmjad4L3H8ncOIR8rnb5MREYqG8ixi5+WbeUsquF0c= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg= -github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= -github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= +github.com/bradenhilton/mozillainstallhash v1.0.1 h1:JVAVsItiWlLoudJX4L+tIuml+hoxjlzCwkhlENi9yS4= +github.com/bradenhilton/mozillainstallhash v1.0.1/go.mod h1:J6cA36kUZrgaTkDl2bHRqI+4i2UKO1ImDB1P1x1PyOA= +github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s= +github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk= +github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng= +github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs= +github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/input v0.1.2 h1:QJAZr33eOhDowkkEQ24rsJy4Llxlm+fRDf/cQrmqJa0= +github.com/charmbracelet/x/input v0.1.2/go.mod h1:LGBim0maUY4Pitjn/4fHnuXb4KirU3DODsyuHuXdOyA= +github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= +github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg= +github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.9 h1:QFrlgFYf2Qpi8bSpVPK1HBvWpx16v/1TZivyo7pGuBE= +github.com/cloudflare/circl v1.3.9/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty/v2 v2.0.0-20231209135443-03db72c7b76c h1:5l8y/PgjeX1aUyZxXabtAf2ahCYQaqWzlFzQgU16o0U= +github.com/creack/pty/v2 v2.0.0-20231209135443-03db72c7b76c/go.mod h1:1gZ4PfMDNcYx8FxDdnF/6HYP327cTeB/ru6UdoWVQvw= +github.com/cyphar/filepath-securejoin v0.3.0 h1:tXpmbiaeBrS/K2US8nhgwdKYnfAOnVfkcLPKFgFHeA0= +github.com/cyphar/filepath-securejoin v0.3.0/go.mod h1:F7i41x/9cBF7lzCrVsYs9fuzwRZm4NQsGTBdpp6mETc= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= -github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= -github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0 h1:dulLQAYQFYtG5MTplgNGHWuV2D+OBD+Z8lmDBmbLg+s= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A= -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/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= -github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -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.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= -github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= -github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= -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.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= -github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34= -github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= -github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8= -github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= -github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4= -github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-ole/go-ole v1.2.6-0.20210915003542-8b1f7f90f6b1 h1:4dntyT+x6QTOSCIrgczbQ+ockAEha0cfxD5Wi0iCzjY= -github.com/go-ole/go-ole v1.2.6-0.20210915003542-8b1f7f90f6b1/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.2 h1:/u628IuisSTwri5/UKloiIsH8+qF2Pu7xEQX+yIKg68= +github.com/dlclark/regexp2 v1.11.2/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad h1:Qk76DOWdOp+GlyDKBAG3Klr9cn7N+LcYc82AZ2S7+cA= +github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad/go.mod h1:mPKfmRa823oBIgl2r20LeMSpTAteW5j7FLkc0vjmzyQ= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/extism/go-sdk v1.3.0 h1:DBd4FzDBUAL3P01MNqUD2+x8G7qyYdJ7pV96NIrfWXA= +github.com/extism/go-sdk v1.3.0/go.mod h1:tPMWfCSOThie3LSTSZKbrQjRm2oAXxUUjSE4HJWjYQM= +github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= +github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= +github.com/fatih/semgroup v1.2.0 h1:h/OLXwEM+3NNyAdZEpMiH1OzfplU09i2qXPVThGZvyg= +github.com/fatih/semgroup v1.2.0/go.mod h1:1KAD4iIYfXjE4U13B48VM4z9QUwV5Tt8O4rS879kgm8= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gitleaks/go-gitdiff v0.9.0 h1:SHAU2l0ZBEo8g82EeFewhVy81sb7JCxW76oSPtR/Nqg= +github.com/gitleaks/go-gitdiff v0.9.0/go.mod h1:pKz0X4YzCKZs30BL+weqBIG7mx0jl4tF1uXV9ZyNvrA= +github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= +github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= +github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro= -github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-github/v40 v40.0.0 h1:oBPVDaIhdUmwDWRRH8XJ/dZG+Rn755i08+Hp1uJHlR0= -github.com/google/go-github/v40 v40.0.0/go.mod h1:G8wWKTEjUCL0zdbaQvpwDk0hqf6KZgPQH+ssJa+/NVc= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v63 v63.0.0 h1:13xwK/wk9alSokujB9lJkuzdmQuVn2QCPeck76wR3nE= +github.com/google/go-github/v63 v63.0.0/go.mod h1:IqbcrgUmIcEaioWrGYei/09o+ge5vhffGOcxrO0AfmA= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gops v0.3.22 h1:lyvhDxfPLHAOR2xIYwjPhN387qHxyU21Sk9sz/GhmhQ= -github.com/google/gops v0.3.22/go.mod h1:7diIdLsqpCihPSX3fQagksT/Ku/y4RL9LHTlKyEUDl8= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= -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/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ= -github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg= github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.1.0 h1:6DWmvNpomjL1+3liNSZbVns3zsYzzCjm6pRBO1tLeso= -github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA= +github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= +github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720 h1:zC34cGQu69FG7qzJ3WiKW244WfhDC3xxYMeNOX2gtUQ= +github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/api v1.10.1 h1:MwZJp86nlnL+6+W1Zly4JUuVn9YHhMggBirMpHGD7kw= -github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/consul/sdk v0.8.0 h1:OJtKBtEjboEZvG6AOUdh4Z1Zbyu0WcxQ0qatRrZHTVU= -github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-hclog v0.12.0 h1:d4QkX8FRTYaKaCZBoXYY8zJX2BXjWxurN/GA2tkrmZM= -github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3 h1:zKjpN5BK/P5lMYrLmBHdBULWbJ0XpYR+7NGzqkZzoD4= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= -github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/hashicorp/consul/api v1.28.2 h1:mXfkRHrpHN4YY3RqL09nXU1eHKLNiuAN4kHvDQ16k/8= +github.com/hashicorp/consul/api v1.28.2/go.mod h1:KyzqzgMEya+IZPcD65YFoOVAgPpbfERu4I/tzG6/ueE= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1 h1:sNCoNyDEvN1xa+X0baata4RdcpKwcMS6DH+xwfqPgjw= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/mdns v1.0.1 h1:XFSOubp8KWB+Jd2PDyaX5xUd5bhSP/+pTDZVDMzZJM8= -github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/memberlist v0.2.2 h1:5+RffWKwqJ71YPu9mWsF7ZOscZmwfasdA8kbdC7AO2g= -github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hashicorp/serf v0.9.5 h1:EBWvyu9tcRszt3Bxp3KNssBMP1KuHWyO51lz9+786iM= -github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= -github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= -github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639 h1:mV02weKRL81bEnm8A0HT1/CAelMQDBuQIfLw8n+d6xI= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= +github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= -github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.16 h1:yLfgLxhIr/6sJNVmYfQjTIv0jGctu6/DgDoivmxTr7g= +github.com/itchyny/gojq v0.12.16/go.mod h1:6abHbdC2uB9ogMS38XsErnfqJ94UlngIJGlRAIj4jTM= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= -github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= -github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/kevinburke/ssh_config v1.1.0 h1:pH/t1WS9NzT8go394IqZeJTMHVm6Cr6ZJ6AQ+mdNo/o= -github.com/kevinburke/ssh_config v1.1.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19 h1:WjT3fLi9n8YWh/Ih8Q1LHAPsTqGddPcHqscN+PJ3i68= -github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19/go.mod h1:hY+WOq6m2FpbvyrI93sMaypsttvaIL5nhVR92dTMUcQ= -github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= -github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= -github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/lucasjones/reggen v0.0.0-20200904144131-37ba4fa293bb h1:w1g9wNDIE/pHSTmAaUhv4TZQuPBS6GV3mMz5hkgziIU= +github.com/lucasjones/reggen v0.0.0-20200904144131-37ba4fa293bb/go.mod h1:5ELEyG+X8f+meRWHuqUOewBOhvHkl7M76pdGEansxW4= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/microcosm-cc/bluemonday v1.0.6/go.mod h1:HOT/6NaBlR0f9XlxD3zolN6Z3N8Lp4pvhp+jLS5ihnI= -github.com/microcosm-cc/bluemonday v1.0.16 h1:kHmAq2t7WPWLjiGvzKa5o3HzSfahUKiOq7fAPUiMNIc= -github.com/microcosm-cc/bluemonday v1.0.16/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.26 h1:gPxPSwALAeHJSjarOs00QjVdV9QoBvc1D2ujQUr5BzU= -github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/cli v1.1.0 h1:tEElEatulEHDeedTxwckzyYMA5c86fbmNIUL1hBIiTg= -github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0 h1:lfGJxY7ToLJQjHHwi0EX6uYBdK78egf954SQl13PQJc= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0 h1:C+X3KsSTLFVBr/tK1eYN/vs4rJcvsiLU338UhYPJWeY= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo= -github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mmcloughlin/avo v0.5.0 h1:nAco9/aI9Lg2kiuROBY6BhCI/z0t5jEvJfjWbL8qXLU= +github.com/mmcloughlin/avo v0.5.0/go.mod h1:ChHFdoV7ql95Wi7vuq2YT1bwCJqiWdZrQ1im3VujLYM= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.7.0 h1:r3y12KyNxj/Sb/iOE46ws+3mS1+MZca1wlHQFPsY/JU= +github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0= -github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8= -github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= -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= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/nats-io/nats.go v1.34.0 h1:fnxnPCNiwIG5w08rlMcEKTUw4AV/nKyGCOJE8TdhSPk= +github.com/nats-io/nats.go v1.34.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= +github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= +github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c h1:Lgl0gzECD8GnQ5QCWA8o6BtfL6mDH5rQgM4/fX3avOs= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= -github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -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 h1:VasscCm72135zRysgrJDKsntdmPN+OuU3+nnHYA9wyc= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= -github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4= -github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.26.0 h1:ORM4ibhEZeTeQlCojCK2kPz1ogAY4bGs4tD+SaAdGaE= -github.com/rs/zerolog v1.26.0/go.mod h1:yBiM87lvSqX8h0Ww4sdzNSkVYZ8dL2xjZJG1lAuGZEo= -github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f h1:UFr9zpz4xgTnIE5yIMtWAMngCdZ9p/+q6lTbgelo80M= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sagikazarmark/crypt v0.1.0 h1:AyO7PGna28P9TMH93Bsxd7m9QC4xE6zyGQTXCo7ZrA8= -github.com/sagikazarmark/crypt v0.1.0/go.mod h1:B/mN0msZuINBtQ1zZLEQcegFJJf9vnYIR88KRMEuODE= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/shirou/gopsutil/v3 v3.21.9 h1:Vn4MUz2uXhqLSiCbGFRc0DILbMVLAY92DSkT8bsYrHg= -github.com/shirou/gopsutil/v3 v3.21.9/go.mod h1:YWp/H8Qs5fVmf17v7JNZzA0mPJ+mS2e9JdiUF9LlKzQ= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/crypt v0.19.0 h1:WMyLTjHBo64UvNcWqpzY3pbZTYgnemZU8FBZigKc42E= +github.com/sagikazarmark/crypt v0.19.0/go.mod h1:c6vimRziqqERhtSe0MhIvzE1w54FrCHtrXb5NH/ja78= +github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= +github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -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.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -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/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= -github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= +github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= -github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw= -github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= -github.com/spf13/viper v1.9.0 h1:yR6EXjTp0y0cLN8OZg1CRZmOBdI88UcGkhgyJhu6nZk= -github.com/spf13/viper v1.9.0/go.mod h1:+i6ajR7OX2XaiBkrcZJFK21htRk7eDeLg7+O6bhUPP4= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= -github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo= -github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs= -github.com/tklauser/numcpus v0.3.0 h1:ILuRUQBtssgnxw0XXIjKUC56fgnOrFoQQ/4+DeU2biQ= -github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8= -github.com/twpayne/go-pinentry v0.0.2 h1:xncgnq3VGWgOq5gMx1SmK++7PrfvkvqZZY00SijBASs= -github.com/twpayne/go-pinentry v0.0.2/go.mod h1:OUbsOnVXqvfSr8PZzFkSNJdBTJOPepfM0NSlDmR5paY= -github.com/twpayne/go-shell v0.3.1 h1:JIC6cyDpG/p8mRnFUleH07roi90q0J9QC8PnvtmYLRo= -github.com/twpayne/go-shell v0.3.1/go.mod h1:H/gzux0DOH5jsjQSHXs6rs2Onxy+V4j6ycZTOulC0l8= -github.com/twpayne/go-vfs/v3 v3.0.0 h1:rMFBISZVhSowKeX1BxL8utM64V7CuUw9/rfekPdS7to= -github.com/twpayne/go-vfs/v3 v3.0.0/go.mod h1:JKfJtOC57Wqo7xYxFRKSqdXwyj3AhfyRrDxSE/XiAVA= -github.com/twpayne/go-vfs/v4 v4.1.0 h1:58tmzvh3AEKgqXlRZvlKn9HD6g1Q9T9bYdob0GhARrs= -github.com/twpayne/go-vfs/v4 v4.1.0/go.mod h1:05bOnh2SNnRsIp/ensn6WLeHSttxklPlQzi4JtTGofc= -github.com/twpayne/go-xdg/v6 v6.0.0 h1:kt2KGpflK5q8ZpkmQfX6kJphh6+oAWikf4LiAZxFT0Y= -github.com/twpayne/go-xdg/v6 v6.0.0/go.mod h1:XlfiGBU0iBxudVRWh+SXF+I1Cfb7rMq1IFwOprG4Ts8= -github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= -github.com/xanzy/ssh-agent v0.3.1 h1:AmzO1SSWxw73zxFZPRwaMN1MohDw8UyHnmuxyceTEGo= -github.com/xanzy/ssh-agent v0.3.1/go.mod h1:QIE4lCeL7nkC25x+yA3LBIYfwCc1TFziCtG7cBAac6w= -github.com/xlab/treeprint v1.1.0 h1:G/1DjNkPpfZCFt9CSh6b5/nY4VimlbHF3Rh4obvtzDk= -github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= -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.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.3/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs= -github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= -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.1 h1:w2V9lcx/Uj4l+dzAf1m9s+DJ1O8ROkEHnynonHjTcYE= -github.com/zalando/go-keyring v0.1.1/go.mod h1:OIC+OZ28XbmwFxU/Rp9V7eKzZjamBJwRzC8UFJH9+L8= -go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= -go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= -go.etcd.io/etcd/api/v3 v3.5.0 h1:GsV3S+OfZEOCNXdtNkBSR7kgLobAa/SO6tCxRa0GAYw= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0 h1:2aQv6F436YnN7I4VbI8PPYrBhu+SmrTaADcf8Mi/6PU= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0 h1:ftQ0nOOHMcbMS3KIaDQ0g5Qcd6bhaBrQT6b89DfwLTs= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -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.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/proto/otlp v0.7.0 h1:rwOQPCuKAKmwGKq2aVNnYIibI6wnV7EvzgfTCzcdGg8= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/tetratelabs/wazero v1.7.3 h1:PBH5KVahrt3S2AHgEjKu4u+LlDbbk+nsGE3KLucy6Rw= +github.com/tetratelabs/wazero v1.7.3/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y= +github.com/twpayne/go-expect v0.0.1 h1:cRJ552FIdQzs4z98Q2OLQsGLSbkB7Xpm/IU6cyQ6mUM= +github.com/twpayne/go-expect v0.0.1/go.mod h1:+ffr+YtUt8ifebyvRQ3NhVTiLch/HnfxsAQqO5LeXss= +github.com/twpayne/go-pinentry/v4 v4.0.0 h1:8WcNa+UDVRzz7y9OEEU/nRMX+UGFPCAvl5XsqWRxTY4= +github.com/twpayne/go-pinentry/v4 v4.0.0/go.mod h1:aXvy+awVXqdH+GS0ddQ7AKHZ3tXM6fJ2NK+e16p47PI= +github.com/twpayne/go-shell v0.4.0 h1:RAAMbjEj7mcwDdwC7SiFHGUKR+WDAURU6mnyd3r2p2E= +github.com/twpayne/go-shell v0.4.0/go.mod h1:MP3aUA0TQ3IGoJc15ahjb+7A7wZH4NeGrvLZ/aFQsHc= +github.com/twpayne/go-vfs/v5 v5.0.4 h1:/ne3h+rW7f5YOyOFguz+3ztfUwzOLR0Vts3y0mMAitg= +github.com/twpayne/go-vfs/v5 v5.0.4/go.mod h1:zTPFJUbgsEMFNSWnWQlLq9wh4AN83edZzx3VXbxrS1w= +github.com/twpayne/go-xdg/v6 v6.1.3 h1:viM0S9v4KAc0IRW2xI3Zp8ZkqOCoCxmCmVZ7GTnG0y0= +github.com/twpayne/go-xdg/v6 v6.1.3/go.mod h1:kVT9oShzQ0Cb5r4gzwziZUfluW2sTR72slQC/4N1AXI= +github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= +github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= +github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= +github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= +github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +github.com/zricethezav/gitleaks/v8 v8.18.4 h1:mWOfVGO8ksok21iOb7h4DZMcUxyvsol8l6o1uNOQxww= +github.com/zricethezav/gitleaks/v8 v8.18.4/go.mod h1:3EFYK+ZNDHPNQinyZTVGHG7/sFsApEZ9DrCGA1AP63M= +go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= +go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= +go.etcd.io/etcd/api/v3 v3.5.12 h1:W4sw5ZoU2Juc9gBWuLk5U6fHfNVyY1WC5g9uiXZio/c= +go.etcd.io/etcd/api/v3 v3.5.12/go.mod h1:Ot+o0SWSyT6uHhA56al1oCED0JImsRiU9Dc26+C2a+4= +go.etcd.io/etcd/client/pkg/v3 v3.5.12 h1:EYDL6pWwyOsylrQyLp2w+HkQ46ATiOvoEdMarindU2A= +go.etcd.io/etcd/client/pkg/v3 v3.5.12/go.mod h1:seTzl2d9APP8R5Y2hFL3NVlD6qC/dOT+3kvrqPyTas4= +go.etcd.io/etcd/client/v2 v2.305.12 h1:0m4ovXYo1CHaA/Mp3X/Fak5sRNIWf01wk/X1/G3sGKI= +go.etcd.io/etcd/client/v2 v2.305.12/go.mod h1:aQ/yhsxMu+Oht1FOupSr60oBvcS9cKXHrzBpDsPTf9E= +go.etcd.io/etcd/client/v3 v3.5.12 h1:v5lCPXn1pf1Uu3M4laUE2hp/geOTc5uPcYYsNe1lDxg= +go.etcd.io/etcd/client/v3 v3.5.12/go.mod h1:tSbBCakoWmmddL+BKVAJHa9km+O/E+bumDe9mSbPiqw= +go.etcd.io/gofail v0.1.0 h1:XItAMIhOojXFQMgrxjnd2EIIHun/d5qL0Pf7FzVTkFg= +go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec= -go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= -go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI= -golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto/x509roots/fallback v0.0.0-20240709155400-d66d9c31b4ae h1:3Lr8+tydLa3EM5BMsp0/++1Pca9P3T3lnTTj6z5BqOc= +golang.org/x/crypto/x509roots/fallback v0.0.0-20240709155400-d66d9c31b4ae/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= -golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0= -golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -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-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/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-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210902050250-f475640dd07b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210903071746-97244b99971b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1 h1:kwrAHlwJ0DUBZwQ238v+Uod/3eZ8B2K5rYsUHBQvzmI= -golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -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-20190907020128-2ca718005c18/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= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ= -golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= -google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= -google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= -google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= -google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= -google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= -google.golang.org/api v0.56.0 h1:08F9XVYTLOGeSQb3xI9C0gXMuQanhdGed0cWFhDozbI= -google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= -google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= -google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71 h1:z+ErRPu0+KS02Td3fOAgdX+lnPDh/VyaABEJPD4JRQs= -google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.40.0 h1:AGJ0Ih4mHjSeibYkFGh1dD9KJ/eOtZ93I6hoHhukQ5Q= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +google.golang.org/api v0.171.0 h1:w174hnBPqut76FzW5Qaupt7zY8Kql6fiVjgys4f58sU= +google.golang.org/api v0.171.0/go.mod h1:Hnq5AHm4OTMt2BUVjael2CWZFD6vksJdWCWiUAmjC9o= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s= +google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2 h1:rIo7ocm2roD9DcFIX67Ym8icoGCKSARAiPljFhh5suQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c h1:lfpJ/2rWPa/kJgxyyXM8PrNnfCzcmxJ265mADgwmvLI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.63.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.64.0 h1:Mj2zXEXcNb5joEiSA0zc3HZpTst/iyjNiR4CN8tDzOg= -gopkg.in/ini.v1 v1.64.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -howett.net/plist v0.0.0-20201203080718-1454fab16a06 h1:QDxUo/w2COstK1wIBYpzQlHX/NqaQTcf9jyz347nI58= -howett.net/plist v0.0.0-20201203080718-1454fab16a06/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= -rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/goversion v1.2.0 h1:SPn+NLTiAG7w30IRK/DKp1BjvpWabYgxlLp/+kx5J8w= -rsc.io/goversion v1.2.0/go.mod h1:Eih9y/uIBS3ulggl7KNJ09xGSLcuNaLgmvvqa07sgfo= -rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +mvdan.cc/editorconfig v0.2.1-0.20231228180347-1925077f8eb2 h1:8nmqQGVnHUtHuT+yvuA49lQK0y5il5IOr2PtCBkDI2M= +mvdan.cc/editorconfig v0.2.1-0.20231228180347-1925077f8eb2/go.mod h1:r8RiQJRtzrPrZdcdEs5VCMqvRxAzYDUu9a4S9z7fKh8= +mvdan.cc/sh/v3 v3.8.0 h1:ZxuJipLZwr/HLbASonmXtcvvC9HXY9d2lXZHnKGjFc8= +mvdan.cc/sh/v3 v3.8.0/go.mod h1:w04623xkgBVo7/IUK89E0g8hBykgEpN0vgOj3RJr6MY= diff --git a/internal/archivetest/archivetest.go b/internal/archivetest/archivetest.go new file mode 100644 index 00000000000..108cf27b124 --- /dev/null +++ b/internal/archivetest/archivetest.go @@ -0,0 +1,23 @@ +// Package archivetest provides useful functions for testing archives. +package archivetest + +import ( + "io/fs" +) + +// A Dir represents a directory. +type Dir struct { + Perm fs.FileMode + Entries map[string]any +} + +// A File represents a file. +type File struct { + Perm fs.FileMode + Contents []byte +} + +// A Symlink represents a symlink. +type Symlink struct { + Target string +} diff --git a/internal/archivetest/tar.go b/internal/archivetest/tar.go new file mode 100644 index 00000000000..c27e44217c3 --- /dev/null +++ b/internal/archivetest/tar.go @@ -0,0 +1,106 @@ +package archivetest + +import ( + "archive/tar" + "bytes" + "fmt" + "io/fs" + + "github.com/twpayne/chezmoi/v2/internal/chezmoimaps" +) + +// NewTar returns the bytes of a new tar archive containing root. +func NewTar(root map[string]any) ([]byte, error) { + buffer := &bytes.Buffer{} + tarWriter := tar.NewWriter(buffer) + for _, key := range chezmoimaps.SortedKeys(root) { + if err := tarAddEntry(tarWriter, key, root[key]); err != nil { + return nil, err + } + } + if err := tarWriter.Close(); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func tarAddEntry(w *tar.Writer, name string, entry any) error { + switch entry := entry.(type) { + case []byte: + if err := w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: name, + Size: int64(len(entry)), + Mode: 0o666, + }); err != nil { + return err + } + if _, err := w.Write(entry); err != nil { + return err + } + + case map[string]any: + if err := w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeDir, + Name: name + "/", + Mode: int64(fs.ModePerm), + }); err != nil { + return err + } + for _, key := range chezmoimaps.SortedKeys(entry) { + if err := tarAddEntry(w, name+"/"+key, entry[key]); err != nil { + return err + } + } + case nil: + return nil + case string: + if err := w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: name, + Size: int64(len(entry)), + Mode: 0o666, + }); err != nil { + return err + } + if _, err := w.Write([]byte(entry)); err != nil { + return err + } + case *Dir: + if err := w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeDir, + Name: name + "/", + Mode: int64(entry.Perm), + }); err != nil { + return err + } + for _, key := range chezmoimaps.SortedKeys(entry.Entries) { + if err := tarAddEntry(w, name+"/"+key, entry.Entries[key]); err != nil { + return err + } + } + case *File: + if err := w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: name, + Size: int64(len(entry.Contents)), + Mode: int64(entry.Perm), + }); err != nil { + return err + } + if _, err := w.Write(entry.Contents); err != nil { + return err + } + case *Symlink: + if err := w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeSymlink, + Name: name, + Linkname: entry.Target, + }); err != nil { + return err + } + default: + return fmt.Errorf("%s: unsupported type: %T", name, entry) + } + return nil +} diff --git a/internal/archivetest/tar_test.go b/internal/archivetest/tar_test.go new file mode 100644 index 00000000000..6bcf14687e3 --- /dev/null +++ b/internal/archivetest/tar_test.go @@ -0,0 +1,73 @@ +package archivetest + +import ( + "archive/tar" + "bytes" + "io/fs" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestNewTar(t *testing.T) { + data, err := NewTar(map[string]any{ + "dir": map[string]any{ + "file1": "# contents of dir/file1\n", + "file2": []byte("# contents of dir/file2\n"), + "subdir": &Dir{ + Perm: 0o700, + Entries: map[string]any{ + "file": &File{ + Perm: fs.ModePerm, + Contents: []byte("# contents of dir/subdir/file\n"), + }, + "symlink": &Symlink{ + Target: "file", + }, + }, + }, + }, + }) + assert.NoError(t, err) + + tarReader := tar.NewReader(bytes.NewBuffer(data)) + + header, err := tarReader.Next() + assert.NoError(t, err) + assert.Equal(t, byte(tar.TypeDir), header.Typeflag) + assert.Equal(t, "dir/", header.Name) + assert.Equal(t, int64(fs.ModePerm), header.Mode) + + header, err = tarReader.Next() + assert.NoError(t, err) + assert.Equal(t, byte(tar.TypeReg), header.Typeflag) + assert.Equal(t, "dir/file1", header.Name) + assert.Equal(t, int64(len("# contents of dir/file1\n")), header.Size) + assert.Equal(t, int64(0o666), header.Mode) + + header, err = tarReader.Next() + assert.NoError(t, err) + assert.Equal(t, byte(tar.TypeReg), header.Typeflag) + assert.Equal(t, "dir/file2", header.Name) + assert.Equal(t, int64(len("# contents of dir/file2\n")), header.Size) + assert.Equal(t, int64(0o666), header.Mode) + + header, err = tarReader.Next() + assert.NoError(t, err) + assert.Equal(t, byte(tar.TypeDir), header.Typeflag) + assert.Equal(t, "dir/subdir/", header.Name) + assert.Equal(t, int64(0o700), header.Mode) + + header, err = tarReader.Next() + assert.NoError(t, err) + assert.Equal(t, byte(tar.TypeReg), header.Typeflag) + assert.Equal(t, "dir/subdir/file", header.Name) + assert.Equal(t, int64(len("# contents of dir/subdir/file\n")), header.Size) + assert.Equal(t, int64(fs.ModePerm), header.Mode) + + header, err = tarReader.Next() + assert.NoError(t, err) + assert.Equal(t, byte(tar.TypeSymlink), header.Typeflag) + assert.Equal(t, "dir/subdir/symlink", header.Name) + assert.Equal(t, "file", header.Linkname) +} diff --git a/internal/archivetest/zip.go b/internal/archivetest/zip.go new file mode 100644 index 00000000000..c5f9f5cbeb7 --- /dev/null +++ b/internal/archivetest/zip.go @@ -0,0 +1,89 @@ +package archivetest + +import ( + "bytes" + "fmt" + "io/fs" + + "github.com/klauspost/compress/zip" + + "github.com/twpayne/chezmoi/v2/internal/chezmoimaps" +) + +func NewZip(root map[string]any) ([]byte, error) { + buffer := &bytes.Buffer{} + zipWriter := zip.NewWriter(buffer) + for _, key := range chezmoimaps.SortedKeys(root) { + if err := zipAddEntry(zipWriter, key, root[key]); err != nil { + return nil, err + } + } + if err := zipWriter.Close(); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func zipAddEntry(w *zip.Writer, name string, entry any) error { + switch entry := entry.(type) { + case []byte: + return zipAddEntryFile(w, name, entry, 0o666) + case map[string]any: + return zipAddEntryDir(w, name, fs.ModePerm, entry) + case string: + return zipAddEntryFile(w, name, []byte(entry), 0o666) + case *Dir: + return zipAddEntryDir(w, name, entry.Perm, entry.Entries) + case *File: + return zipAddEntryFile(w, name, entry.Contents, entry.Perm) + case *Symlink: + return zipAddEntrySymlink(w, name, []byte(entry.Target)) + default: + return fmt.Errorf("%s: unsupported type: %T", name, entry) + } +} + +func zipAddEntryDir(w *zip.Writer, name string, perm fs.FileMode, entries map[string]any) error { + fileHeader := zip.FileHeader{ + Name: name, + } + fileHeader.SetMode(fs.ModeDir | perm) + if _, err := w.CreateHeader(&fileHeader); err != nil { + return err + } + for _, key := range chezmoimaps.SortedKeys(entries) { + if err := zipAddEntry(w, name+"/"+key, entries[key]); err != nil { + return err + } + } + return nil +} + +func zipAddEntryFile(w *zip.Writer, name string, data []byte, perm fs.FileMode) error { + fileHeader := zip.FileHeader{ + Name: name, + Method: zip.Deflate, + UncompressedSize64: uint64(len(data)), + } + fileHeader.SetMode(perm) + fileWriter, err := w.CreateHeader(&fileHeader) + if err != nil { + return err + } + _, err = fileWriter.Write(data) + return err +} + +func zipAddEntrySymlink(w *zip.Writer, name string, target []byte) error { + fileHeader := zip.FileHeader{ + Name: name, + UncompressedSize64: uint64(len(target)), + } + fileHeader.SetMode(fs.ModeSymlink) + fileWriter, err := w.CreateHeader(&fileHeader) + if err != nil { + return err + } + _, err = fileWriter.Write(target) + return err +} diff --git a/internal/archivetest/zip_test.go b/internal/archivetest/zip_test.go new file mode 100644 index 00000000000..2fa129d1e70 --- /dev/null +++ b/internal/archivetest/zip_test.go @@ -0,0 +1,78 @@ +package archivetest + +import ( + "bytes" + "io/fs" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/klauspost/compress/zip" +) + +func TestNewZip(t *testing.T) { + data, err := NewZip(map[string]any{ + "dir": map[string]any{ + "file1": "# contents of dir/file1\n", + "file2": []byte("# contents of dir/file2\n"), + "subdir": &Dir{ + Perm: 0o700, + Entries: map[string]any{ + "file": &File{ + Perm: fs.ModePerm, + Contents: []byte("# contents of dir/subdir/file\n"), + }, + "symlink": &Symlink{ + Target: "file", + }, + }, + }, + }, + }) + assert.NoError(t, err) + + zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + assert.NoError(t, err) + + fileIndex := 0 + nextFile := func() *zip.File { + assert.True(t, fileIndex <= len(zipReader.File)) + zipFile := zipReader.File[fileIndex] + fileIndex++ + return zipFile + } + + zipFile := nextFile() + assert.Equal(t, "dir", zipFile.Name) + assert.Equal(t, fs.ModeDir, zipFile.FileInfo().Mode().Type()) + assert.Equal(t, fs.ModePerm, zipFile.FileInfo().Mode().Perm()) + + zipFile = nextFile() + assert.Equal(t, "dir/file1", zipFile.Name) + assert.Equal(t, fs.FileMode(0), zipFile.FileInfo().Mode().Type()) + assert.Equal(t, fs.FileMode(0o666), zipFile.FileInfo().Mode().Perm()) + assert.Equal(t, uint64(len("# contents of dir/file1\n")), zipFile.UncompressedSize64) + + zipFile = nextFile() + assert.Equal(t, "dir/file2", zipFile.Name) + assert.Equal(t, fs.FileMode(0), zipFile.FileInfo().Mode().Type()) + assert.Equal(t, fs.FileMode(0o666), zipFile.FileInfo().Mode().Perm()) + assert.Equal(t, uint64(len("# contents of dir/file2\n")), zipFile.UncompressedSize64) + + zipFile = nextFile() + assert.Equal(t, "dir/subdir", zipFile.Name) + assert.Equal(t, fs.ModeDir, zipFile.FileInfo().Mode().Type()) + assert.Equal(t, fs.FileMode(0o700), zipFile.FileInfo().Mode().Perm()) + + zipFile = nextFile() + assert.Equal(t, "dir/subdir/file", zipFile.Name) + assert.Equal(t, fs.FileMode(0), zipFile.FileInfo().Mode().Type()) + assert.Equal(t, fs.ModePerm, zipFile.FileInfo().Mode().Perm()) + assert.Equal(t, uint64(len("# contents of dir/subdir/file\n")), zipFile.UncompressedSize64) + + zipFile = nextFile() + assert.Equal(t, "dir/subdir/symlink", zipFile.Name) + assert.Equal(t, fs.ModeSymlink, zipFile.FileInfo().Mode().Type()) + assert.Equal(t, uint64(len("file")), zipFile.UncompressedSize64) + + assert.Equal(t, fileIndex, len(zipReader.File)) +} diff --git a/internal/chezmoi/abspath.go b/internal/chezmoi/abspath.go index f09604917dc..701a1a40be7 100644 --- a/internal/chezmoi/abspath.go +++ b/internal/chezmoi/abspath.go @@ -2,7 +2,6 @@ package chezmoi import ( "fmt" - "os" "path" "path/filepath" "reflect" @@ -18,9 +17,7 @@ var ( ) // An AbsPath is an absolute path. -type AbsPath struct { - absPath string -} +type AbsPath string // AbsPaths is a slice of RelPaths that implements sort.Interface. type AbsPaths []AbsPath @@ -31,67 +28,65 @@ func (ps AbsPaths) Swap(i, j int) { ps[i], ps[j] = ps[j], ps[i] } // NewAbsPath returns a new AbsPath. func NewAbsPath(absPath string) AbsPath { - return AbsPath{ - absPath: absPath, - } + return AbsPath(filepath.ToSlash(absPath)) +} + +// Append appends s to p. +func (p AbsPath) Append(s string) AbsPath { + return NewAbsPath(string(p) + s) } // Base returns p's basename. func (p AbsPath) Base() string { - return path.Base(p.absPath) + return path.Base(string(p)) } // Bytes returns p as a []byte. func (p AbsPath) Bytes() []byte { - return []byte(p.absPath) + return []byte(p) } // Dir returns p's directory. func (p AbsPath) Dir() AbsPath { - return NewAbsPath(path.Dir(p.absPath)) + return NewAbsPath(filepath.Dir(string(p))).ToSlash() } // Empty returns if p is empty. func (p AbsPath) Empty() bool { - return p.absPath == "" + return p == "" } // Ext returns p's extension. func (p AbsPath) Ext() string { - return path.Ext(p.absPath) + return path.Ext(string(p)) } // Join returns a new AbsPath with relPaths appended. func (p AbsPath) Join(relPaths ...RelPath) AbsPath { - relPathStrs := make([]string, 0, len(relPaths)+1) - relPathStrs = append(relPathStrs, p.absPath) - for _, relPath := range relPaths { - relPathStrs = append(relPathStrs, relPath.String()) + relPathStrs := make([]string, len(relPaths)+1) + relPathStrs[0] = string(p) + for i, relPath := range relPaths { + relPathStrs[i+1] = relPath.String() } return NewAbsPath(path.Join(relPathStrs...)) } // JoinString returns a new AbsPath with ss appended. func (p AbsPath) JoinString(ss ...string) AbsPath { - strs := make([]string, 0, len(ss)+1) - strs = append(strs, p.absPath) - strs = append(strs, ss...) + strs := make([]string, len(ss)+1) + strs[0] = string(p) + copy(strs[1:len(ss)+1], ss) return NewAbsPath(path.Join(strs...)) } // Len returns the length of p. func (p AbsPath) Len() int { - return len(p.absPath) + return len(p) } // Less returns if p is less than other. func (p AbsPath) Less(other AbsPath) bool { - return p.absPath < other.absPath -} - -// MarshalText implements encoding.TextMarshaler.MarshalText. -func (p AbsPath) MarshalText() ([]byte, error) { - return []byte(p.absPath), nil + return p < other } // MustTrimDirPrefix is like TrimPrefix but panics on any error. @@ -106,7 +101,7 @@ func (p AbsPath) MustTrimDirPrefix(dirPrefix AbsPath) RelPath { // Set implements github.com/spf13/pflag.Value.Set. func (p *AbsPath) Set(s string) error { if s == "" { - p.absPath = "" + *p = "" return nil } homeDirAbsPath, err := HomeDirAbsPath() @@ -128,12 +123,12 @@ func (p AbsPath) Split() (AbsPath, RelPath) { } func (p AbsPath) String() string { - return p.absPath + return string(p) } // ToSlash calls filepath.ToSlash on p. func (p AbsPath) ToSlash() AbsPath { - return NewAbsPath(filepath.ToSlash(p.absPath)) + return NewAbsPath(filepath.ToSlash(string(p))) } // TrimDirPrefix trims prefix from p. @@ -142,16 +137,21 @@ func (p AbsPath) TrimDirPrefix(dirPrefixAbsPath AbsPath) (RelPath, error) { return EmptyRelPath, nil } dirAbsPath := dirPrefixAbsPath - if dirAbsPath.absPath != "/" { - dirAbsPath.absPath += "/" + if !strings.HasSuffix(string(dirAbsPath), "/") { + dirAbsPath += "/" } - if !strings.HasPrefix(p.absPath, dirAbsPath.absPath) { - return EmptyRelPath, ¬InAbsDirError{ + if !strings.HasPrefix(string(p), string(dirAbsPath)) { + return EmptyRelPath, &NotInAbsDirError{ pathAbsPath: p, dirAbsPath: dirPrefixAbsPath, } } - return NewRelPath(p.absPath[len(dirAbsPath.absPath):]), nil + return NewRelPath(string(p[len(dirAbsPath):])), nil +} + +// TrimSuffix returns p with the optional suffix removed. +func (p AbsPath) TrimSuffix(suffix string) AbsPath { + return NewAbsPath(strings.TrimSuffix(string(p), suffix)) } // Type implements github.com/spf13/pflag.Value.Type. @@ -159,14 +159,9 @@ func (p AbsPath) Type() string { return "path" } -// UnmarshalText implements encoding.TextUnmarshaler.UnmarshalText. -func (p *AbsPath) UnmarshalText(text []byte) error { - return p.Set(string(text)) -} - // HomeDirAbsPath returns the user's home directory as an AbsPath. func HomeDirAbsPath() (AbsPath, error) { - userHomeDir, err := os.UserHomeDir() + userHomeDir, err := UserHomeDir() if err != nil { return EmptyAbsPath, err } @@ -180,7 +175,7 @@ func HomeDirAbsPath() (AbsPath, error) { // StringToAbsPathHookFunc is a github.com/mitchellh/mapstructure.DecodeHookFunc // that parses an AbsPath from a string. func StringToAbsPathHookFunc() mapstructure.DecodeHookFunc { - return func(from, to reflect.Type, data interface{}) (interface{}, error) { + return func(from, to reflect.Type, data any) (any, error) { if to != reflect.TypeOf(EmptyAbsPath) { return data, nil } diff --git a/internal/chezmoi/abspath_test.go b/internal/chezmoi/abspath_test.go index 845327693d8..c8ab18d962d 100644 --- a/internal/chezmoi/abspath_test.go +++ b/internal/chezmoi/abspath_test.go @@ -4,19 +4,18 @@ import ( "os" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/alecthomas/assert/v2" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) func TestNewAbsPathFromExtPath(t *testing.T) { wd, err := os.Getwd() - require.NoError(t, err) + assert.NoError(t, err) wdAbsPath := NewAbsPath(wd) - require.NoError(t, err) + assert.NoError(t, err) homeDirAbsPath, err := NormalizePath(chezmoitest.HomeDir()) - require.NoError(t, err) + assert.NoError(t, err) for _, tc := range []struct { name string @@ -52,11 +51,11 @@ func TestNewAbsPathFromExtPath(t *testing.T) { chezmoitest.SkipUnlessGOOS(t, tc.name) actual, err := NewAbsPathFromExtPath(tc.extPath, homeDirAbsPath) - require.NoError(t, err) + assert.NoError(t, err) normalizedActual, err := NormalizePath(actual.String()) - require.NoError(t, err) + assert.NoError(t, err) expected, err := NormalizePath(tc.expected.String()) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, expected, normalizedActual) }) } diff --git a/internal/chezmoi/actualstateentry.go b/internal/chezmoi/actualstateentry.go index 4437fb511b2..131e3cb43d6 100644 --- a/internal/chezmoi/actualstateentry.go +++ b/internal/chezmoi/actualstateentry.go @@ -11,6 +11,7 @@ type ActualStateEntry interface { EntryState() (*EntryState, error) Path() AbsPath Remove(system System) error + OriginString() string } // A ActualStateAbsent represents the absence of an entry in the filesystem. @@ -69,11 +70,11 @@ func NewActualStateEntry(system System, absPath AbsPath, fileInfo fs.FileInfo, e return &ActualStateSymlink{ absPath: absPath, lazyLinkname: newLazyLinknameFunc(func() (string, error) { - linkame, err := system.Readlink(absPath) + linkname, err := system.Readlink(absPath) if err != nil { return "", err } - return normalizeLinkname(linkame), nil + return normalizeLinkname(linkname), nil }), }, nil default: @@ -101,6 +102,11 @@ func (s *ActualStateAbsent) Remove(system System) error { return nil } +// OriginString returns s's origin. +func (s *ActualStateAbsent) OriginString() string { + return s.absPath.String() +} + // EntryState returns s's entry state. func (s *ActualStateDir) EntryState() (*EntryState, error) { return &EntryState{ @@ -119,6 +125,11 @@ func (s *ActualStateDir) Remove(system System) error { return system.RemoveAll(s.absPath) } +// OriginString returns s's origin. +func (s *ActualStateDir) OriginString() string { + return s.absPath.String() +} + // EntryState returns s's entry state. func (s *ActualStateFile) EntryState() (*EntryState, error) { contents, err := s.Contents() @@ -152,6 +163,11 @@ func (s *ActualStateFile) Remove(system System) error { return system.RemoveAll(s.absPath) } +// OriginString returns s's origin. +func (s *ActualStateFile) OriginString() string { + return s.absPath.String() +} + // EntryState returns s's entry state. func (s *ActualStateSymlink) EntryState() (*EntryState, error) { linkname, err := s.Linkname() @@ -178,3 +194,8 @@ func (s *ActualStateSymlink) Path() AbsPath { func (s *ActualStateSymlink) Remove(system System) error { return system.RemoveAll(s.absPath) } + +// OriginString returns s's origin. +func (s *ActualStateSymlink) OriginString() string { + return s.absPath.String() +} diff --git a/internal/chezmoi/ageencryption.go b/internal/chezmoi/ageencryption.go index f0dea5c97f8..302a29e5812 100644 --- a/internal/chezmoi/ageencryption.go +++ b/internal/chezmoi/ageencryption.go @@ -1,51 +1,46 @@ package chezmoi -// FIXME add builtin support for --passphrase -// FIXME add builtin support for --symmetric -// FIXME add builtin support for SSH keys if recommended - import ( "bytes" "io" + "log/slog" "os" "os/exec" "filippo.io/age" "filippo.io/age/armor" - "go.uber.org/multierr" + "github.com/twpayne/chezmoi/v2/internal/chezmoierrors" "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) // An AgeEncryption uses age for encryption and decryption. See // https://age-encryption.org. type AgeEncryption struct { - UseBuiltin bool - BaseSystem System - Command string - Args []string - Identity AbsPath - Identities []AbsPath - Passphrase bool - Recipient string - Recipients []string - RecipientsFile AbsPath - RecipientsFiles []AbsPath - Suffix string - Symmetric bool + UseBuiltin bool `json:"useBuiltin" mapstructure:"useBuiltin" yaml:"useBuiltin"` + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + Identity AbsPath `json:"identity" mapstructure:"identity" yaml:"identity"` + Identities []AbsPath `json:"identities" mapstructure:"identities" yaml:"identities"` + Passphrase bool `json:"passphrase" mapstructure:"passphrase" yaml:"passphrase"` + Recipient string `json:"recipient" mapstructure:"recipient" yaml:"recipient"` + Recipients []string `json:"recipients" mapstructure:"recipients" yaml:"recipients"` + RecipientsFile AbsPath `json:"recipientsFile" mapstructure:"recipientsFile" yaml:"recipientsFile"` + RecipientsFiles []AbsPath `json:"recipientsFiles" mapstructure:"recipientsFiles" yaml:"recipientsFiles"` + Suffix string `json:"suffix" mapstructure:"suffix" yaml:"suffix"` + Symmetric bool `json:"symmetric" mapstructure:"symmetric" yaml:"symmetric"` } -// Decrypt implements Encyrption.Decrypt. +// Decrypt implements Encryption.Decrypt. func (e *AgeEncryption) Decrypt(ciphertext []byte) ([]byte, error) { if e.UseBuiltin { return e.builtinDecrypt(ciphertext) } - //nolint:gosec - cmd := exec.Command(e.Command, append(e.decryptArgs(), e.Args...)...) + cmd := exec.Command(e.Command, append(e.decryptArgs(), e.Args...)...) //nolint:gosec cmd.Stdin = bytes.NewReader(ciphertext) cmd.Stderr = os.Stderr - return chezmoilog.LogCmdOutput(cmd) + return chezmoilog.LogCmdOutput(slog.Default(), cmd) } // DecryptToFile implements Encryption.DecryptToFile. @@ -55,14 +50,14 @@ func (e *AgeEncryption) DecryptToFile(plaintextAbsPath AbsPath, ciphertext []byt if err != nil { return err } - return e.BaseSystem.WriteFile(plaintextAbsPath, plaintext, 0o644) + return os.WriteFile(plaintextAbsPath.String(), plaintext, 0o644) //nolint:gosec } - //nolint:gosec - cmd := exec.Command(e.Command, append(append(e.decryptArgs(), "--output", plaintextAbsPath.String()), e.Args...)...) + args := append(append(e.decryptArgs(), "--output", plaintextAbsPath.String()), e.Args...) + cmd := exec.Command(e.Command, args...) //nolint:gosec cmd.Stdin = bytes.NewReader(ciphertext) cmd.Stderr = os.Stderr - return chezmoilog.LogCmdRun(cmd) + return chezmoilog.LogCmdRun(slog.Default(), cmd) } // Encrypt implements Encryption.Encrypt. @@ -71,27 +66,26 @@ func (e *AgeEncryption) Encrypt(plaintext []byte) ([]byte, error) { return e.builtinEncrypt(plaintext) } - //nolint:gosec - cmd := exec.Command(e.Command, append(e.encryptArgs(), e.Args...)...) + cmd := exec.Command(e.Command, append(e.encryptArgs(), e.Args...)...) //nolint:gosec cmd.Stdin = bytes.NewReader(plaintext) cmd.Stderr = os.Stderr - return chezmoilog.LogCmdOutput(cmd) + return chezmoilog.LogCmdOutput(slog.Default(), cmd) } // EncryptFile implements Encryption.EncryptFile. func (e *AgeEncryption) EncryptFile(plaintextAbsPath AbsPath) ([]byte, error) { if e.UseBuiltin { - plaintext, err := e.BaseSystem.ReadFile(plaintextAbsPath) + plaintext, err := os.ReadFile(plaintextAbsPath.String()) if err != nil { return nil, err } return e.builtinEncrypt(plaintext) } - //nolint:gosec - cmd := exec.Command(e.Command, append(append(e.encryptArgs(), e.Args...), plaintextAbsPath.String())...) + args := append(append(e.encryptArgs(), e.Args...), plaintextAbsPath.String()) + cmd := exec.Command(e.Command, args...) //nolint:gosec cmd.Stderr = os.Stderr - return chezmoilog.LogCmdOutput(cmd) + return chezmoilog.LogCmdOutput(slog.Default(), cmd) } // EncryptedSuffix implements Encryption.EncryptedSuffix. @@ -105,15 +99,19 @@ func (e *AgeEncryption) builtinDecrypt(ciphertext []byte) ([]byte, error) { if err != nil { return nil, err } - r, err := age.Decrypt(armor.NewReader(bytes.NewReader(ciphertext)), identities...) + var ciphertextReader io.Reader = bytes.NewReader(ciphertext) + if bytes.HasPrefix(ciphertext, []byte(armor.Header)) { + ciphertextReader = armor.NewReader(ciphertextReader) + } + plaintextReader, err := age.Decrypt(ciphertextReader, identities...) if err != nil { return nil, err } - buffer := &bytes.Buffer{} - if _, err = io.Copy(buffer, r); err != nil { + plaintextBuffer := &bytes.Buffer{} + if _, err := io.Copy(plaintextBuffer, plaintextReader); err != nil { return nil, err } - return buffer.Bytes(), err + return plaintextBuffer.Bytes(), nil } // builtinEncrypt encrypts ciphertext using the builtin age. @@ -122,22 +120,22 @@ func (e *AgeEncryption) builtinEncrypt(plaintext []byte) ([]byte, error) { if err != nil { return nil, err } - output := &bytes.Buffer{} - armorWriter := armor.NewWriter(output) - writer, err := age.Encrypt(armorWriter, recipients...) + ciphertextBuffer := &bytes.Buffer{} + armoredCiphertextWriter := armor.NewWriter(ciphertextBuffer) + ciphertextWriteCloser, err := age.Encrypt(armoredCiphertextWriter, recipients...) if err != nil { return nil, err } - if _, err := io.Copy(writer, bytes.NewReader(plaintext)); err != nil { + if _, err := io.Copy(ciphertextWriteCloser, bytes.NewReader(plaintext)); err != nil { return nil, err } - if err := writer.Close(); err != nil { + if err := ciphertextWriteCloser.Close(); err != nil { return nil, err } - if err := armorWriter.Close(); err != nil { + if err := armoredCiphertextWriter.Close(); err != nil { return nil, err } - return output.Bytes(), nil + return ciphertextBuffer.Bytes(), nil } // builtinIdentities returns the identities for decryption using the builtin @@ -247,30 +245,26 @@ func (e *AgeEncryption) identityArgs() []string { return args } -// parseIdentityFile parses the identities from indentityFile using the builtin +// parseIdentityFile parses the identities from identityFile using the builtin // age. func parseIdentityFile(identityFile AbsPath) (identities []age.Identity, err error) { var file *os.File if file, err = os.Open(identityFile.String()); err != nil { return } - defer func() { - err = multierr.Append(err, file.Close()) - }() + defer chezmoierrors.CombineFunc(&err, file.Close) identities, err = age.ParseIdentities(file) return } -// parseRecipientFile parses the recipients from recipientFile using the builtin -// age. +// parseRecipientsFile parses the recipients from recipientsFile using the +// builtin age. func parseRecipientsFile(recipientsFile AbsPath) (recipients []age.Recipient, err error) { var file *os.File if file, err = os.Open(recipientsFile.String()); err != nil { return } - defer func() { - err = multierr.Append(err, file.Close()) - }() + defer chezmoierrors.CombineFunc(&err, file.Close) recipients, err = age.ParseRecipients(file) return } diff --git a/internal/chezmoi/ageencryption_test.go b/internal/chezmoi/ageencryption_test.go index 970454188b3..c00c761bd56 100644 --- a/internal/chezmoi/ageencryption_test.go +++ b/internal/chezmoi/ageencryption_test.go @@ -6,72 +6,214 @@ import ( "testing" "filippo.io/age" - "github.com/stretchr/testify/require" - "github.com/twpayne/go-vfs/v4" + "github.com/alecthomas/assert/v2" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) +var ageCommands = []string{ + "age", + "rage", +} + func TestAgeEncryption(t *testing.T) { - command := lookPathOrSkip(t, "age") + forEachAgeCommand(t, func(t *testing.T, command string) { + t.Helper() - identityFile := filepath.Join(t.TempDir(), "chezmoi-test-age-key.txt") - recipient, err := chezmoitest.AgeGenerateKey(identityFile) - require.NoError(t, err) + identityFile := filepath.Join(t.TempDir(), "chezmoi-test-age-key.txt") + recipient, err := chezmoitest.AgeGenerateKey(command, identityFile) + assert.NoError(t, err) - testEncryption(t, &AgeEncryption{ - Command: command, - Identity: NewAbsPath(identityFile), - Recipient: recipient, + testEncryption(t, &AgeEncryption{ + Command: command, + Identity: NewAbsPath(identityFile), + Recipient: recipient, + }) }) } +func TestAgeEncryptionMarshalUnmarshal(t *testing.T) { + for _, format := range []Format{ + FormatJSON, + FormatYAML, + } { + t.Run(format.Name(), func(t *testing.T) { + expected := AgeEncryption{ + UseBuiltin: true, + Command: "command", + Args: []string{ + "arg1", + "arg2", + }, + Identity: NewAbsPath("/identity"), + Identities: []AbsPath{ + NewAbsPath("/identity1"), + NewAbsPath("/identity2"), + }, + Passphrase: true, + Recipient: "recipient", + RecipientsFile: NewAbsPath("/recipients-file"), + RecipientsFiles: []AbsPath{ + NewAbsPath("/recipients-file1"), + NewAbsPath("/recipients-file2"), + }, + Suffix: "suffix", + Symmetric: true, + } + data, err := format.Marshal(expected) + assert.NoError(t, err) + var actual AgeEncryption + assert.NoError(t, format.Unmarshal(data, &actual)) + assert.Equal(t, expected, actual) + }) + } +} + +func TestAgeEncryptionMarshalUnmarshalField(t *testing.T) { + type ConfigFile struct { + Age AgeEncryption `json:"age" yaml:"age"` + } + for _, format := range []Format{ + FormatJSON, + FormatYAML, + } { + t.Run(format.Name(), func(t *testing.T) { + expected := ConfigFile{ + Age: AgeEncryption{ + UseBuiltin: true, + Command: "command", + Args: []string{ + "arg1", + "arg2", + }, + Identity: NewAbsPath("/identity"), + Identities: []AbsPath{ + NewAbsPath("/identity1"), + NewAbsPath("/identity2"), + }, + Passphrase: true, + Recipient: "recipient", + RecipientsFile: NewAbsPath("/recipients-file"), + RecipientsFiles: []AbsPath{ + NewAbsPath("/recipients-file1"), + NewAbsPath("/recipients-file2"), + }, + Suffix: "suffix", + Symmetric: true, + }, + } + data, err := format.Marshal(expected) + assert.NoError(t, err) + var actual ConfigFile + assert.NoError(t, format.Unmarshal(data, &actual)) + assert.Equal(t, expected, actual) + }) + } +} + +func TestAgeEncryptionMarshalUnmarshalFieldEmbedded(t *testing.T) { + type ConfigFile struct { + Age AgeEncryption `json:"age" yaml:"age"` + } + type Config struct { + ConfigFile + } + for _, format := range []Format{ + FormatJSON, + FormatYAML, + } { + t.Run(format.Name(), func(t *testing.T) { + expected := Config{ + ConfigFile: ConfigFile{ + Age: AgeEncryption{ + UseBuiltin: true, + Command: "command", + Args: []string{ + "arg1", + "arg2", + }, + Identity: NewAbsPath("/identity"), + Identities: []AbsPath{ + NewAbsPath("/identity1"), + NewAbsPath("/identity2"), + }, + Passphrase: true, + Recipient: "recipient", + RecipientsFile: NewAbsPath("/recipients-file"), + RecipientsFiles: []AbsPath{ + NewAbsPath("/recipients-file1"), + NewAbsPath("/recipients-file2"), + }, + Suffix: "suffix", + Symmetric: true, + }, + }, + } + data, err := format.Marshal(expected) + assert.NoError(t, err) + var actual Config + assert.NoError(t, format.Unmarshal(data, &actual)) + assert.Equal(t, expected, actual) + }) + } +} + func TestAgeMultipleIdentitiesAndMultipleRecipients(t *testing.T) { - command := lookPathOrSkip(t, "age") + forEachAgeCommand(t, func(t *testing.T, command string) { + t.Helper() - tempDir := t.TempDir() - identityFile1 := filepath.Join(tempDir, "chezmoi-test-age-key1.txt") - recipient1, err := chezmoitest.AgeGenerateKey(identityFile1) - require.NoError(t, err) - identityFile2 := filepath.Join(tempDir, "chezmoi-test-age-key2.txt") - recipient2, err := chezmoitest.AgeGenerateKey(identityFile2) - require.NoError(t, err) + tempDir := t.TempDir() - testEncryption(t, &AgeEncryption{ - Command: command, - Identities: []AbsPath{ - NewAbsPath(identityFile1), - NewAbsPath(identityFile2), - }, - Recipients: []string{ - recipient1, - recipient2, - }, + identityFile1 := filepath.Join(tempDir, "chezmoi-test-age-key1.txt") + recipient1, err := chezmoitest.AgeGenerateKey(command, identityFile1) + assert.NoError(t, err) + + identityFile2 := filepath.Join(tempDir, "chezmoi-test-age-key2.txt") + recipient2, err := chezmoitest.AgeGenerateKey(command, identityFile2) + assert.NoError(t, err) + + testEncryption(t, &AgeEncryption{ + Command: command, + Identities: []AbsPath{ + NewAbsPath(identityFile1), + NewAbsPath(identityFile2), + }, + Recipients: []string{ + recipient1, + recipient2, + }, + }) }) } func TestAgeRecipientsFile(t *testing.T) { - command := lookPathOrSkip(t, "age") + t.Helper() - tempDir := t.TempDir() - identityFile := filepath.Join(tempDir, "chezmoi-test-age-key.txt") - recipient, err := chezmoitest.AgeGenerateKey(identityFile) - require.NoError(t, err) - recipientsFile := filepath.Join(t.TempDir(), "chezmoi-test-age-recipients.txt") - require.NoError(t, os.WriteFile(recipientsFile, []byte(recipient), 0o666)) + forEachAgeCommand(t, func(t *testing.T, command string) { + t.Helper() - testEncryption(t, &AgeEncryption{ - Command: command, - Identity: NewAbsPath(identityFile), - RecipientsFile: NewAbsPath(recipientsFile), - }) + tempDir := t.TempDir() - testEncryption(t, &AgeEncryption{ - Command: command, - Identity: NewAbsPath(identityFile), - RecipientsFiles: []AbsPath{ - NewAbsPath(recipientsFile), - }, + identityFile := filepath.Join(tempDir, "chezmoi-test-age-key.txt") + recipient, err := chezmoitest.AgeGenerateKey(command, identityFile) + assert.NoError(t, err) + + recipientsFile := filepath.Join(t.TempDir(), "chezmoi-test-age-recipients.txt") + assert.NoError(t, os.WriteFile(recipientsFile, []byte(recipient), 0o666)) + + testEncryption(t, &AgeEncryption{ + Command: command, + Identity: NewAbsPath(identityFile), + RecipientsFile: NewAbsPath(recipientsFile), + }) + + testEncryption(t, &AgeEncryption{ + Command: command, + Identity: NewAbsPath(identityFile), + RecipientsFiles: []AbsPath{ + NewAbsPath(recipientsFile), + }, + }) }) } @@ -80,7 +222,6 @@ func TestBuiltinAgeEncryption(t *testing.T) { testEncryption(t, &AgeEncryption{ UseBuiltin: true, - BaseSystem: NewRealSystem(vfs.OSFS), Identity: identityAbsPath, Recipient: recipientStringer.String(), }) @@ -92,7 +233,6 @@ func TestBuiltinAgeMultipleIdentitiesAndMultipleRecipients(t *testing.T) { testEncryption(t, &AgeEncryption{ UseBuiltin: true, - BaseSystem: NewRealSystem(vfs.OSFS), Identities: []AbsPath{ identityAbsPath1, identityAbsPath2, @@ -105,21 +245,18 @@ func TestBuiltinAgeMultipleIdentitiesAndMultipleRecipients(t *testing.T) { } func TestBuiltinAgeRecipientsFile(t *testing.T) { - baseSystem := NewRealSystem(vfs.OSFS) recipient, identityAbsPath := builtinAgeGenerateKey(t) recipientsFile := filepath.Join(t.TempDir(), "chezmoi-builtin-age-recipients.txt") - require.NoError(t, os.WriteFile(recipientsFile, []byte(recipient.String()), 0o666)) + assert.NoError(t, os.WriteFile(recipientsFile, []byte(recipient.String()), 0o666)) testEncryption(t, &AgeEncryption{ UseBuiltin: true, - BaseSystem: baseSystem, Identity: identityAbsPath, RecipientsFile: NewAbsPath(recipientsFile), }) testEncryption(t, &AgeEncryption{ UseBuiltin: true, - BaseSystem: baseSystem, Identity: identityAbsPath, RecipientsFiles: []AbsPath{ NewAbsPath(recipientsFile), @@ -130,8 +267,17 @@ func TestBuiltinAgeRecipientsFile(t *testing.T) { func builtinAgeGenerateKey(t *testing.T) (*age.X25519Recipient, AbsPath) { t.Helper() identity, err := age.GenerateX25519Identity() - require.NoError(t, err) + assert.NoError(t, err) identityFile := filepath.Join(t.TempDir(), "chezmoi-test-builtin-age-key.txt") - require.NoError(t, os.WriteFile(identityFile, []byte(identity.String()), 0o600)) + assert.NoError(t, os.WriteFile(identityFile, []byte(identity.String()), 0o600)) return identity.Recipient(), NewAbsPath(identityFile) } + +func forEachAgeCommand(t *testing.T, f func(*testing.T, string)) { + t.Helper() + for _, command := range ageCommands { + t.Run(command, func(t *testing.T) { + f(t, lookPathOrSkip(t, command)) + }) + } +} diff --git a/internal/chezmoi/archive.go b/internal/chezmoi/archive.go new file mode 100644 index 00000000000..83b549c7155 --- /dev/null +++ b/internal/chezmoi/archive.go @@ -0,0 +1,322 @@ +package chezmoi + +import ( + "archive/tar" + "bytes" + "compress/bzip2" + "errors" + "fmt" + "io" + "io/fs" + "path" + "strings" + "time" + + "github.com/klauspost/compress/gzip" + "github.com/klauspost/compress/zip" + "github.com/klauspost/compress/zstd" + "github.com/ulikunitz/xz" + + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" +) + +// An ArchiveFormat is an archive format and implements the +// github.com/spf13/pflag.Value interface. +type ArchiveFormat string + +// Archive formats. +const ( + ArchiveFormatUnknown ArchiveFormat = "" + ArchiveFormatTar ArchiveFormat = "tar" + ArchiveFormatTarBz2 ArchiveFormat = "tar.bz2" + ArchiveFormatTarGz ArchiveFormat = "tar.gz" + ArchiveFormatTarXz ArchiveFormat = "tar.xz" + ArchiveFormatTarZst ArchiveFormat = "tar.zst" + ArchiveFormatTbz2 ArchiveFormat = "tbz2" + ArchiveFormatTgz ArchiveFormat = "tgz" + ArchiveFormatTxz ArchiveFormat = "txz" + ArchiveFormatZip ArchiveFormat = "zip" +) + +type UnknownArchiveFormatError string + +func (e UnknownArchiveFormatError) Error() string { + if e == UnknownArchiveFormatError(ArchiveFormatUnknown) { + return "unknown archive format" + } + return string(e) + ": unknown archive format" +} + +// An WalkArchiveFunc is called once for each entry in an archive. +type WalkArchiveFunc func(name string, info fs.FileInfo, r io.Reader, linkname string) error + +// Set implements github.com/spf13/pflag.Value.Set. +func (f *ArchiveFormat) Set(s string) error { + *f = ArchiveFormat(s) + return nil +} + +// String implements github.com/spf13/pflag.Value.String. +func (f ArchiveFormat) String() string { + return string(f) +} + +// Type implements github.com/spf13/pflag.Value.Type. +func (f ArchiveFormat) Type() string { + return "format" +} + +// GuessArchiveFormat guesses the archive format from the path and data. +func GuessArchiveFormat(path string, data []byte) ArchiveFormat { + switch pathLower := strings.ToLower(path); { + case strings.HasSuffix(pathLower, ".tar"): + return ArchiveFormatTar + case strings.HasSuffix(pathLower, ".tar.bz2") || strings.HasSuffix(pathLower, ".tbz2"): + return ArchiveFormatTarBz2 + case strings.HasSuffix(pathLower, ".tar.gz") || strings.HasSuffix(pathLower, ".tgz"): + return ArchiveFormatTarGz + case strings.HasSuffix(pathLower, ".tar.xz") || strings.HasSuffix(pathLower, ".txz"): + return ArchiveFormatTarXz + case strings.HasSuffix(pathLower, ".tar.zst"): + return ArchiveFormatTarZst + case strings.HasSuffix(pathLower, ".zip"): + return ArchiveFormatZip + } + + switch { + case len(data) >= 3 && bytes.Equal(data[:3], []byte{0x1f, 0x8b, 0x08}): + return ArchiveFormatTarGz + case len(data) >= 4 && bytes.Equal(data[:4], []byte{'P', 'K', 0x03, 0x04}): + return ArchiveFormatZip + case len(data) >= xz.HeaderLen && xz.ValidHeader(data): + return ArchiveFormatTarXz + case (&zstd.Header{}).Decode(data) == nil: + return ArchiveFormatTarZst + case isTarArchive(bytes.NewReader(data)): + return ArchiveFormatTar + case isTarArchive(bzip2.NewReader(bytes.NewReader(data))): + return ArchiveFormatTarBz2 + } + + return ArchiveFormatUnknown +} + +// WalkArchive walks over all the entries in an archive. +func WalkArchive(data []byte, format ArchiveFormat, f WalkArchiveFunc) error { + if format == ArchiveFormatZip { + return walkArchiveZip(bytes.NewReader(data), int64(len(data)), f) + } + // r will read bytes in tar format. + var r io.Reader = bytes.NewReader(data) + switch format { + case ArchiveFormatTar: + // Already in tar format, do nothing. + case ArchiveFormatTarBz2, ArchiveFormatTbz2: + // Decompress with bzip2. + r = bzip2.NewReader(r) + case ArchiveFormatTarGz, ArchiveFormatTgz: + // Decompress with gzip. + var err error + r, err = gzip.NewReader(r) + if err != nil { + return err + } + case ArchiveFormatTarXz, ArchiveFormatTxz: + // Decompress with xz. + var err error + r, err = xz.NewReader(r) + if err != nil { + return err + } + case ArchiveFormatTarZst: + // Decompress with zstd. + var err error + r, err = zstd.NewReader(r) + if err != nil { + return err + } + default: + return UnknownArchiveFormatError(format) + } + return walkArchiveTar(r, f) +} + +// isTarArchive returns if r looks like a tar archive. +func isTarArchive(r io.Reader) bool { + tarReader := tar.NewReader(r) + _, err := tarReader.Next() + return err == nil +} + +func implicitDirHeader(dir string, modTime time.Time) *tar.Header { + return &tar.Header{ + Typeflag: tar.TypeDir, + Name: dir, + Mode: 0o777, + Size: 0, + ModTime: modTime, + } +} + +// walkArchiveTar walks over all the entries in a tar archive. +func walkArchiveTar(r io.Reader, f WalkArchiveFunc) error { + tarReader := tar.NewReader(r) + var skippedDirPrefixes []string + seenDirs := chezmoiset.New[string]() + processHeader := func(header *tar.Header, dir string) error { + for _, skippedDirPrefix := range skippedDirPrefixes { + if strings.HasPrefix(header.Name, skippedDirPrefix) { + return fs.SkipDir + } + } + if seenDirs.Contains(dir) { + return nil + } + seenDirs.Add(dir) + name := strings.TrimSuffix(header.Name, "/") + switch err := f(name, header.FileInfo(), tarReader, header.Linkname); { + case errors.Is(err, fs.SkipDir): + skippedDirPrefixes = append(skippedDirPrefixes, header.Name) + case err != nil: + return err + } + return nil + } +HEADER: + for { + header, err := tarReader.Next() + switch { + case errors.Is(err, io.EOF): + return nil + case err != nil: + return err + } + switch header.Typeflag { + case tar.TypeReg, tar.TypeDir, tar.TypeSymlink: + if header.Typeflag == tar.TypeReg { + dirs, _ := path.Split(header.Name) + dirComponents := strings.Split(strings.TrimSuffix(dirs, "/"), "/") + for i := range dirComponents { + dir := strings.Join(dirComponents[0:i+1], "/") + if len(dir) > 0 { + switch err := processHeader(implicitDirHeader(dir+"/", header.ModTime), dir+"/"); { + case errors.Is(err, fs.SkipDir): + continue HEADER + case errors.Is(err, fs.SkipAll): + return nil + case err != nil: + return err + } + } + } + } + switch err := processHeader(header, header.Name); { + case errors.Is(err, fs.SkipDir): + continue HEADER + case errors.Is(err, fs.SkipAll): + return nil + case err != nil: + return err + } + case tar.TypeXGlobalHeader: + // Do nothing. + default: + return fmt.Errorf("%s: unsupported typeflag '%c'", header.Name, header.Typeflag) + } + } +} + +// walkArchiveZip walks over all the entries in a zip archive. +func walkArchiveZip(r io.ReaderAt, size int64, f WalkArchiveFunc) error { + zipReader, err := zip.NewReader(r, size) + if err != nil { + return err + } + var skippedDirPrefixes []string + seenDirs := chezmoiset.New[string]() + processHeader := func(fileInfo fs.FileInfo, dir string) error { + for _, skippedDirPrefix := range skippedDirPrefixes { + if strings.HasPrefix(dir, skippedDirPrefix) { + return fs.SkipDir + } + } + if seenDirs.Contains(dir) { + return nil + } + seenDirs.Add(dir) + name := strings.TrimSuffix(dir, "/") + dirFileInfo := implicitDirHeader(dir, fileInfo.ModTime()).FileInfo() + switch err := f(name, dirFileInfo, nil, ""); { + case errors.Is(err, fs.SkipDir): + skippedDirPrefixes = append(skippedDirPrefixes, dir) + return err + case err != nil: + return err + } + return nil + } +FILE: + for _, zipFile := range zipReader.File { + zipFileReader, err := zipFile.Open() + if err != nil { + return err + } + + name := path.Clean(zipFile.Name) + if strings.HasPrefix(name, "../") || strings.Contains(name, "/../") { + return fmt.Errorf("%s: invalid filename", zipFile.Name) + } + + for _, skippedDirPrefix := range skippedDirPrefixes { + if strings.HasPrefix(zipFile.Name, skippedDirPrefix) { + continue FILE + } + } + + switch fileInfo := zipFile.FileInfo(); fileInfo.Mode() & fs.ModeType { + case 0: + dirs, _ := path.Split(name) + dirComponents := strings.Split(strings.TrimSuffix(dirs, "/"), "/") + for i := range dirComponents { + dir := strings.Join(dirComponents[0:i+1], "/") + if len(dir) > 0 { + switch err := processHeader(fileInfo, dir+"/"); { + case errors.Is(err, fs.SkipDir): + continue FILE + case errors.Is(err, fs.SkipAll): + return nil + case err != nil: + return err + } + } + } + + err = f(name, fileInfo, zipFileReader, "") + case fs.ModeDir: + err = processHeader(fileInfo, name+"/") + case fs.ModeSymlink: + var linknameBytes []byte + linknameBytes, err = io.ReadAll(zipFileReader) + if err != nil { + return err + } + err = f(name, fileInfo, nil, string(linknameBytes)) + } + + err2 := zipFileReader.Close() + + switch { + case errors.Is(err, fs.SkipDir): + skippedDirPrefixes = append(skippedDirPrefixes, zipFile.Name+"/") + case errors.Is(err, fs.SkipAll): + return nil + case err != nil: + return err + } + + if err2 != nil { + return err2 + } + } + return nil +} diff --git a/internal/chezmoi/archive_test.go b/internal/chezmoi/archive_test.go new file mode 100644 index 00000000000..144cdcf43b1 --- /dev/null +++ b/internal/chezmoi/archive_test.go @@ -0,0 +1,119 @@ +package chezmoi + +import ( + "io" + "io/fs" + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/twpayne/chezmoi/v2/internal/archivetest" +) + +func TestWalkArchive(t *testing.T) { + nestedRoot := map[string]any{ + "dir1": map[string]any{ + "subdir1": map[string]any{ + "file1": "", + "file2": "", + }, + "subdir2": map[string]any{ + "file1": "", + "file2": "", + }, + }, + "dir2": map[string]any{ + "subdir1": map[string]any{ + "file1": "", + "file2": "", + }, + "subdir2": map[string]any{ + "file1": "", + "file2": "", + }, + }, + "file1": "", + "file2": "", + "symlink1": &archivetest.Symlink{Target: "file1"}, + "symlink2": &archivetest.Symlink{Target: "file2"}, + } + flatRoot := map[string]any{ + "dir1/subdir1/file1": "", + "dir1/subdir1/file2": "", + "dir1/subdir2/file1": "", + "dir1/subdir2/file2": "", + "dir2/subdir1/file1": "", + "dir2/subdir1/file2": "", + "dir2/subdir2/file1": "", + "dir2/subdir2/file2": "", + "file1": "", + "file2": "", + "symlink1": &archivetest.Symlink{Target: "file1"}, + "symlink2": &archivetest.Symlink{Target: "file2"}, + } + for _, tc := range []struct { + name string + root map[string]any + dataFunc func(map[string]any) ([]byte, error) + archiveFormat ArchiveFormat + }{ + { + name: "tar", + root: nestedRoot, + dataFunc: archivetest.NewTar, + archiveFormat: ArchiveFormatTar, + }, + { + name: "zip", + root: nestedRoot, + dataFunc: archivetest.NewZip, + archiveFormat: ArchiveFormatZip, + }, + { + name: "zip-flat", + root: flatRoot, + dataFunc: archivetest.NewZip, + archiveFormat: ArchiveFormatZip, + }, + { + name: "tar-flat", + root: flatRoot, + dataFunc: archivetest.NewTar, + archiveFormat: ArchiveFormatTar, + }, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := tc.dataFunc(tc.root) + assert.NoError(t, err) + + expectedNames := []string{ + "dir1", + "dir1/subdir1", + "dir1/subdir1/file1", + "dir1/subdir1/file2", + "dir1/subdir2", + "dir2", + "file1", + "file2", + "symlink1", + } + + var actualNames []string + walkArchiveFunc := func(name string, info fs.FileInfo, r io.Reader, linkname string) error { + actualNames = append(actualNames, name) + switch name { + case "dir1/subdir2": + return fs.SkipDir + case "dir2": + return fs.SkipDir + case "symlink1": + return fs.SkipAll + default: + return nil + } + } + assert.NoError(t, WalkArchive(data, tc.archiveFormat, walkArchiveFunc)) + assert.Equal(t, expectedNames, actualNames) + }) + } +} diff --git a/internal/chezmoi/archivereadersystem.go b/internal/chezmoi/archivereadersystem.go index 96c3d326a19..14113d0a585 100644 --- a/internal/chezmoi/archivereadersystem.go +++ b/internal/chezmoi/archivereadersystem.go @@ -1,12 +1,6 @@ package chezmoi import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/bzip2" - "compress/gzip" - "errors" "fmt" "io" "io/fs" @@ -14,33 +8,6 @@ import ( "strings" ) -// An ArchiveFormat is an archive format and implements the -// github.com/spf13/pflag.Value interface. -type ArchiveFormat string - -// Archive formats. -const ( - ArchiveFormatUnknown ArchiveFormat = "" - ArchiveFormatTar ArchiveFormat = "tar" - ArchiveFormatTarBz2 ArchiveFormat = "tar.bz2" - ArchiveFormatTarGz ArchiveFormat = "tar.gz" - ArchiveFormatTbz2 ArchiveFormat = "tbz2" - ArchiveFormatTgz ArchiveFormat = "tgz" - ArchiveFormatZip ArchiveFormat = "zip" -) - -type InvalidArchiveFormatError string - -func (e InvalidArchiveFormatError) Error() string { - if e == InvalidArchiveFormatError(ArchiveFormatUnknown) { - return "invalid archive format" - } - return fmt.Sprintf("%s: invalid archive format", string(e)) -} - -// An walkArchiveFunc is called once for each entry in an archive. -type walkArchiveFunc func(name string, fileInfo fs.FileInfo, r io.Reader, linkname string) error - // A ArchiveReaderSystem a system constructed from reading an archive. type ArchiveReaderSystem struct { emptySystemMixin @@ -59,7 +26,10 @@ type ArchiveReaderSystemOptions struct { // NewArchiveReaderSystem returns a new ArchiveReaderSystem reading from data // and using archivePath as a hint for the archive format. func NewArchiveReaderSystem( - archivePath string, data []byte, format ArchiveFormat, options ArchiveReaderSystemOptions, + archivePath string, + data []byte, + format ArchiveFormat, + options ArchiveReaderSystemOptions, ) (*ArchiveReaderSystem, error) { s := &ArchiveReaderSystem{ fileInfos: make(map[AbsPath]fs.FileInfo), @@ -71,7 +41,7 @@ func NewArchiveReaderSystem( format = GuessArchiveFormat(archivePath, data) } - if err := walkArchive(data, format, func(name string, fileInfo fs.FileInfo, r io.Reader, linkname string) error { + if err := WalkArchive(data, format, func(name string, fileInfo fs.FileInfo, r io.Reader, linkname string) error { if options.StripComponents > 0 { components := strings.Split(name, "/") if len(components) <= options.StripComponents { @@ -87,6 +57,7 @@ func NewArchiveReaderSystem( s.fileInfos[nameAbsPath] = fileInfo switch { case fileInfo.IsDir(): + // Do nothing. case fileInfo.Mode()&fs.ModeType == 0: contents, err := io.ReadAll(r) if err != nil { @@ -141,139 +112,3 @@ func (s *ArchiveReaderSystem) Readlink(name AbsPath) (string, error) { } return "", fs.ErrNotExist } - -// Set implements github.com/spf13/pflag.Value.Set. -func (f *ArchiveFormat) Set(s string) error { - *f = ArchiveFormat(s) - return nil -} - -// String implements github.com/spf13/pflag.Value.String. -func (f ArchiveFormat) String() string { - return string(f) -} - -// Type implements github.com/spf13/pflag.Value.Type. -func (f ArchiveFormat) Type() string { - return "format" -} - -// GuessArchiveFormat guesses the archive format from the path and data. -func GuessArchiveFormat(path string, data []byte) ArchiveFormat { - switch pathLower := strings.ToLower(path); { - case strings.HasSuffix(pathLower, ".tar"): - return ArchiveFormatTar - case strings.HasSuffix(pathLower, ".tar.bz2") || strings.HasSuffix(pathLower, ".tbz2"): - return ArchiveFormatTarBz2 - case strings.HasSuffix(pathLower, ".tar.gz") || strings.HasSuffix(pathLower, ".tgz"): - return ArchiveFormatTarGz - case strings.HasSuffix(pathLower, ".zip"): - return ArchiveFormatZip - } - - switch { - case len(data) >= 3 && bytes.Equal(data[:3], []byte{0x1f, 0x8b, 0x08}): - return ArchiveFormatTarGz - case len(data) >= 4 && bytes.Equal(data[:4], []byte{'P', 'K', 0x03, 0x04}): - return ArchiveFormatZip - case isTarArchive(bytes.NewReader(data)): - return ArchiveFormatTar - case isTarArchive(bzip2.NewReader(bytes.NewReader(data))): - return ArchiveFormatTarBz2 - } - - return ArchiveFormatUnknown -} - -// isTarArchive returns if r looks like a tar archive. -func isTarArchive(r io.Reader) bool { - tarReader := tar.NewReader(r) - _, err := tarReader.Next() - return err == nil -} - -// walkArchive walks over all the entries in an archive. -func walkArchive(data []byte, format ArchiveFormat, f walkArchiveFunc) error { - if format == ArchiveFormatZip { - return walkArchiveZip(bytes.NewReader(data), int64(len(data)), f) - } - var r io.Reader = bytes.NewReader(data) - switch format { - case ArchiveFormatTar: - case ArchiveFormatTarBz2, ArchiveFormatTbz2: - r = bzip2.NewReader(r) - case ArchiveFormatTarGz, ArchiveFormatTgz: - var err error - r, err = gzip.NewReader(r) - if err != nil { - return err - } - default: - return InvalidArchiveFormatError(format) - } - return walkArchiveTar(r, f) -} - -// walkArchiveTar walks over all the entries in a tar archive. -func walkArchiveTar(r io.Reader, f walkArchiveFunc) error { - tarReader := tar.NewReader(r) - for { - header, err := tarReader.Next() - switch { - case errors.Is(err, io.EOF): - return nil - case err != nil: - return err - } - name := strings.TrimSuffix(header.Name, "/") - switch header.Typeflag { - case tar.TypeDir, tar.TypeReg: - if err := f(name, header.FileInfo(), tarReader, ""); err != nil { - return err - } - case tar.TypeSymlink: - if err := f(name, header.FileInfo(), nil, header.Linkname); err != nil { - return err - } - case tar.TypeXGlobalHeader: - default: - return fmt.Errorf("%s: unsupported typeflag '%c'", header.Name, header.Typeflag) - } - } -} - -// walkArchiveZip walks over all the entries in a zip archive. -func walkArchiveZip(r io.ReaderAt, size int64, f walkArchiveFunc) error { - zipReader, err := zip.NewReader(r, size) - if err != nil { - return err - } - for _, zipFile := range zipReader.File { - zipFileReader, err := zipFile.Open() - if err != nil { - return err - } - name := path.Clean(zipFile.Name) - if strings.HasPrefix(name, "../") { - return fmt.Errorf("%s: invalid filename", zipFile.Name) - } - switch fileInfo := zipFile.FileInfo(); fileInfo.Mode() & fs.ModeType { - case 0: - err = f(name, fileInfo, zipFileReader, "") - case fs.ModeDir: - err = f(name, fileInfo, nil, "") - case fs.ModeSymlink: - var linknameBytes []byte - linknameBytes, err = io.ReadAll(zipFileReader) - if err != nil { - return err - } - err = f(name, fileInfo, nil, string(linknameBytes)) - } - zipFileReader.Close() - if err != nil { - return err - } - } - return nil -} diff --git a/internal/chezmoi/archivereadersystem_test.go b/internal/chezmoi/archivereadersystem_test.go index c5c73a87606..635d2157f4a 100644 --- a/internal/chezmoi/archivereadersystem_test.go +++ b/internal/chezmoi/archivereadersystem_test.go @@ -1,45 +1,31 @@ package chezmoi import ( - "archive/tar" - "bytes" "errors" "io/fs" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/alecthomas/assert/v2" + + "github.com/twpayne/chezmoi/v2/internal/archivetest" ) -func TestArchiveReaderSystemTAR(t *testing.T) { - buffer := &bytes.Buffer{} - tarWriter := tar.NewWriter(buffer) - assert.NoError(t, tarWriter.WriteHeader(&tar.Header{ - Typeflag: tar.TypeDir, - Name: "dir/", - Mode: 0o777, - })) - data := []byte("# contents of dir/file\n") - assert.NoError(t, tarWriter.WriteHeader(&tar.Header{ - Typeflag: tar.TypeReg, - Name: "dir/file", - Size: int64(len(data)), - Mode: 0o666, - })) - _, err := tarWriter.Write(data) +func TestArchiveReaderSystemTar(t *testing.T) { + data, err := archivetest.NewTar(map[string]any{ + "dir": map[string]any{ + "file": "# contents of dir/file\n", + "symlink": &archivetest.Symlink{ + Target: "file", + }, + }, + }) assert.NoError(t, err) - linkname := "file" - assert.NoError(t, tarWriter.WriteHeader(&tar.Header{ - Typeflag: tar.TypeSymlink, - Name: "dir/symlink", - Linkname: linkname, - })) - require.NoError(t, tarWriter.Close()) - archiveReaderSystem, err := NewArchiveReaderSystem("archive.tar", buffer.Bytes(), ArchiveFormatTar, ArchiveReaderSystemOptions{ + options := ArchiveReaderSystemOptions{ RootAbsPath: NewAbsPath("/home/user"), StripComponents: 1, - }) + } + archiveReaderSystem, err := NewArchiveReaderSystem("archive.tar", data, ArchiveFormatTar, options) assert.NoError(t, err) for _, tc := range []struct { @@ -53,10 +39,10 @@ func TestArchiveReaderSystemTAR(t *testing.T) { { absPath: NewAbsPath("/home/user/file"), readlinkErr: fs.ErrInvalid, - readFileData: data, + readFileData: []byte("# contents of dir/file\n"), }, { - absPath: NewAbsPath("/home/user/notexist"), + absPath: NewAbsPath("/home/user/not-exist"), readlinkErr: fs.ErrNotExist, lstatErr: fs.ErrNotExist, readFileErr: fs.ErrNotExist, diff --git a/internal/chezmoi/attr.go b/internal/chezmoi/attr.go index 28fcf69b8e9..3c1d83e7822 100644 --- a/internal/chezmoi/attr.go +++ b/internal/chezmoi/attr.go @@ -2,9 +2,8 @@ package chezmoi import ( "io/fs" + "log/slog" "strings" - - "github.com/rs/zerolog" ) // A SourceFileTargetType is a the type of a target represented by a file in the @@ -46,7 +45,8 @@ type ScriptCondition string // Script conditions. const ( - ScriptConditionAlways ScriptCondition = "" + ScriptConditionNone ScriptCondition = "" + ScriptConditionAlways ScriptCondition = "always" ScriptConditionOnce ScriptCondition = "once" ScriptConditionOnChange ScriptCondition = "onchange" ) @@ -55,8 +55,10 @@ const ( type DirAttr struct { TargetName string Exact bool + External bool Private bool ReadOnly bool + Remove bool } // A FileAttr holds attributes parsed from a source file name. @@ -74,51 +76,49 @@ type FileAttr struct { } // parseDirAttr parses a single directory name in the source state. -func parseDirAttr(sourceName string) DirAttr { - var ( - name = sourceName - exact = false - private = false - readOnly = 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, readOnlyPrefix) { - name = mustTrimPrefix(name, readOnlyPrefix) - readOnly = true - } +func parseDirAttr(name string) DirAttr { + name, remove := strings.CutPrefix(name, removePrefix) + name, external := strings.CutPrefix(name, externalPrefix) + name, exact := strings.CutPrefix(name, exactPrefix) + name, private := strings.CutPrefix(name, privatePrefix) + name, readOnly := strings.CutPrefix(name, readOnlyPrefix) switch { case strings.HasPrefix(name, dotPrefix): - name = "." + mustTrimPrefix(name, dotPrefix) + name = "." + name[len(dotPrefix):] case strings.HasPrefix(name, literalPrefix): name = name[len(literalPrefix):] } return DirAttr{ TargetName: name, Exact: exact, + External: external, Private: private, ReadOnly: readOnly, + Remove: remove, } } -// MarshalZerologObject implements -// github.com/rs/zerolog.ObjectMarshaler.MarshalZerologObject. -func (da DirAttr) MarshalZerologObject(e *zerolog.Event) { - e.Str("targetName", da.TargetName) - e.Bool("exact", da.Exact) - e.Bool("private", da.Private) - e.Bool("readOnly", da.ReadOnly) +// LogValue implements log/slog.LogValuer.LogValue. +func (da DirAttr) LogValue() slog.Value { + return slog.GroupValue( + slog.String("TargetName", da.TargetName), + slog.Bool("Exact", da.Exact), + slog.Bool("External", da.External), + slog.Bool("Private", da.Private), + slog.Bool("ReadOnly", da.ReadOnly), + slog.Bool("Remove", da.Remove), + ) } // SourceName returns da's source name. func (da DirAttr) SourceName() string { sourceName := "" + if da.Remove { + sourceName += removePrefix + } + if da.External { + sourceName += externalPrefix + } if da.Exact { sourceName += exactPrefix } @@ -130,8 +130,8 @@ func (da DirAttr) SourceName() string { } switch { case strings.HasPrefix(da.TargetName, "."): - sourceName += dotPrefix + mustTrimPrefix(da.TargetName, ".") - case dirPrefixRegexp.MatchString(da.TargetName): + sourceName += dotPrefix + da.TargetName[len("."):] + case dirPrefixRx.MatchString(da.TargetName): sourceName += literalPrefix + da.TargetName default: sourceName += da.TargetName @@ -141,7 +141,7 @@ func (da DirAttr) SourceName() string { // perm returns da's file mode. func (da DirAttr) perm() fs.FileMode { - perm := fs.FileMode(0o777) + perm := fs.ModePerm if da.Private { perm &^= 0o77 } @@ -156,7 +156,7 @@ func parseFileAttr(sourceName, encryptedSuffix string) FileAttr { var ( sourceFileType = SourceFileTypeFile name = sourceName - condition = ScriptConditionAlways + condition = ScriptConditionNone empty = false encrypted = false executable = false @@ -168,107 +168,69 @@ func parseFileAttr(sourceName, encryptedSuffix string) FileAttr { switch { case strings.HasPrefix(name, createPrefix): sourceFileType = SourceFileTypeCreate - name = mustTrimPrefix(name, createPrefix) - 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, readOnlyPrefix) { - name = mustTrimPrefix(name, readOnlyPrefix) - readOnly = true - } - if strings.HasPrefix(name, executablePrefix) { - name = mustTrimPrefix(name, executablePrefix) - executable = true - } + name = name[len(createPrefix):] + name, encrypted = strings.CutPrefix(name, encryptedPrefix) + name, private = strings.CutPrefix(name, privatePrefix) + name, readOnly = strings.CutPrefix(name, readOnlyPrefix) + name, empty = strings.CutPrefix(name, emptyPrefix) + name, executable = strings.CutPrefix(name, executablePrefix) case strings.HasPrefix(name, removePrefix): sourceFileType = SourceFileTypeRemove - name = mustTrimPrefix(name, removePrefix) + name = name[len(removePrefix):] case strings.HasPrefix(name, runPrefix): sourceFileType = SourceFileTypeScript - name = mustTrimPrefix(name, runPrefix) + name = name[len(runPrefix):] switch { case strings.HasPrefix(name, oncePrefix): - name = mustTrimPrefix(name, oncePrefix) + name = name[len(oncePrefix):] condition = ScriptConditionOnce case strings.HasPrefix(name, onChangePrefix): - name = mustTrimPrefix(name, onChangePrefix) + name = name[len(onChangePrefix):] condition = ScriptConditionOnChange + default: + condition = ScriptConditionAlways } switch { case strings.HasPrefix(name, beforePrefix): - name = mustTrimPrefix(name, beforePrefix) + name = name[len(beforePrefix):] order = ScriptOrderBefore case strings.HasPrefix(name, afterPrefix): - name = mustTrimPrefix(name, afterPrefix) + name = name[len(afterPrefix):] order = ScriptOrderAfter } case strings.HasPrefix(name, symlinkPrefix): sourceFileType = SourceFileTypeSymlink - name = mustTrimPrefix(name, symlinkPrefix) + name = name[len(symlinkPrefix):] case strings.HasPrefix(name, modifyPrefix): sourceFileType = SourceFileTypeModify - name = mustTrimPrefix(name, modifyPrefix) - 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, readOnlyPrefix) { - name = mustTrimPrefix(name, readOnlyPrefix) - readOnly = true - } - if strings.HasPrefix(name, executablePrefix) { - name = mustTrimPrefix(name, executablePrefix) - executable = true - } + name = name[len(modifyPrefix):] + name, encrypted = strings.CutPrefix(name, encryptedPrefix) + name, private = strings.CutPrefix(name, privatePrefix) + name, readOnly = strings.CutPrefix(name, readOnlyPrefix) + name, executable = strings.CutPrefix(name, executablePrefix) 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, readOnlyPrefix) { - name = mustTrimPrefix(name, readOnlyPrefix) - readOnly = true - } - if strings.HasPrefix(name, emptyPrefix) { - name = mustTrimPrefix(name, emptyPrefix) - empty = true - } - if strings.HasPrefix(name, executablePrefix) { - name = mustTrimPrefix(name, executablePrefix) - executable = true - } + name, encrypted = strings.CutPrefix(name, encryptedPrefix) + name, private = strings.CutPrefix(name, privatePrefix) + name, readOnly = strings.CutPrefix(name, readOnlyPrefix) + name, empty = strings.CutPrefix(name, emptyPrefix) + name, executable = strings.CutPrefix(name, executablePrefix) } switch { case strings.HasPrefix(name, dotPrefix): - name = "." + mustTrimPrefix(name, dotPrefix) + name = "." + name[len(dotPrefix):] case strings.HasPrefix(name, literalPrefix): name = name[len(literalPrefix):] } if encrypted { - name = strings.TrimSuffix(name, encryptedSuffix) + name, _ = strings.CutSuffix(name, encryptedSuffix) } switch { case strings.HasSuffix(name, literalSuffix): - name = mustTrimSuffix(name, literalSuffix) + name = name[:len(name)-len(literalSuffix)] case strings.HasSuffix(name, TemplateSuffix): - name = mustTrimSuffix(name, TemplateSuffix) + name = name[:len(name)-len(TemplateSuffix)] template = true - if strings.HasSuffix(name, literalSuffix) { - name = mustTrimSuffix(name, literalSuffix) - } + name, _ = strings.CutSuffix(name, literalSuffix) } return FileAttr{ TargetName: name, @@ -284,19 +246,20 @@ func parseFileAttr(sourceName, encryptedSuffix string) FileAttr { } } -// MarshalZerologObject implements -// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. -func (fa FileAttr) MarshalZerologObject(e *zerolog.Event) { - e.Str("TargetName", fa.TargetName) - e.Str("Type", sourceFileTypeStrs[fa.Type]) - e.Str("Condition", string(fa.Condition)) - e.Bool("Empty", fa.Empty) - e.Bool("Encrypted", fa.Encrypted) - e.Bool("Executable", fa.Executable) - e.Int("Order", int(fa.Order)) - e.Bool("Private", fa.Private) - e.Bool("ReadOnly", fa.ReadOnly) - e.Bool("Template", fa.Template) +// LogValue implements log/slog.LogValuer.LogValue. +func (fa FileAttr) LogValue() slog.Value { + return slog.GroupValue( + slog.String("TargetName", fa.TargetName), + slog.String("Type", sourceFileTypeStrs[fa.Type]), + slog.String("Condition", string(fa.Condition)), + slog.Bool("Empty", fa.Empty), + slog.Bool("Encrypted", fa.Encrypted), + slog.Bool("Executable", fa.Executable), + slog.Int("Order", int(fa.Order)), + slog.Bool("Private", fa.Private), + slog.Bool("ReadOnly", fa.ReadOnly), + slog.Bool("Template", fa.Template), + ) } // SourceName returns fa's source name. @@ -314,6 +277,9 @@ func (fa FileAttr) SourceName(encryptedSuffix string) string { if fa.ReadOnly { sourceName += readOnlyPrefix } + if fa.Empty { + sourceName += emptyPrefix + } if fa.Executable { sourceName += executablePrefix } @@ -335,6 +301,9 @@ func (fa FileAttr) SourceName(encryptedSuffix string) string { } case SourceFileTypeModify: sourceName = modifyPrefix + if fa.Encrypted { + sourceName += encryptedPrefix + } if fa.Private { sourceName += privatePrefix } @@ -365,13 +334,13 @@ func (fa FileAttr) SourceName(encryptedSuffix string) string { } switch { case strings.HasPrefix(fa.TargetName, "."): - sourceName += dotPrefix + mustTrimPrefix(fa.TargetName, ".") - case filePrefixRegexp.MatchString(fa.TargetName): + sourceName += dotPrefix + fa.TargetName[len("."):] + case filePrefixRx.MatchString(fa.TargetName): sourceName += literalPrefix + fa.TargetName default: sourceName += fa.TargetName } - if fileSuffixRegexp.MatchString(fa.TargetName) { + if fileSuffixRx.MatchString(fa.TargetName) { sourceName += literalSuffix } if fa.Template { diff --git a/internal/chezmoi/attr_test.go b/internal/chezmoi/attr_test.go index 6b454ddacd1..c4ecb5f1295 100644 --- a/internal/chezmoi/attr_test.go +++ b/internal/chezmoi/attr_test.go @@ -4,37 +4,40 @@ import ( "io/fs" "testing" + "github.com/alecthomas/assert/v2" "github.com/muesli/combinator" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestDirAttr(t *testing.T) { - testData := struct { + var dirAttrs []DirAttr + targetNames := []string{ + ".dir", + "dir.tmpl", + "dir", + "exact_dir", + "empty_dir", + "encrypted_dir", + "executable_dir", + "once_dir", + "run_dir", + "run_once_dir", + "symlink_dir", + } + assert.NoError(t, combinator.Generate(&dirAttrs, struct { TargetName []string Exact []bool + External []bool Private []bool ReadOnly []bool + Remove []bool }{ - TargetName: []string{ - ".dir", - "dir.tmpl", - "dir", - "exact_dir", - "empty_dir", - "encrypted_dir", - "executable_dir", - "once_dir", - "run_dir", - "run_once_dir", - "symlink_dir", - }, - Exact: []bool{false, true}, - Private: []bool{false, true}, - ReadOnly: []bool{false, true}, - } - var dirAttrs []DirAttr - require.NoError(t, combinator.Generate(&dirAttrs, testData)) + TargetName: targetNames, + Exact: []bool{false, true}, + External: []bool{false, true}, + Private: []bool{false, true}, + ReadOnly: []bool{false, true}, + Remove: []bool{false, true}, + })) for _, dirAttr := range dirAttrs { actualSourceName := dirAttr.SourceName() actualDirAttr := parseDirAttr(actualSourceName) @@ -87,13 +90,15 @@ func TestFileAttr(t *testing.T) { "modify_name", "name.literal", "name", + "remove_", "run_name", "symlink_name", "template.tmpl", } - require.NoError(t, combinator.Generate(&fileAttrs, struct { + assert.NoError(t, combinator.Generate(&fileAttrs, struct { Type SourceFileTargetType TargetName []string + Empty []bool Encrypted []bool Executable []bool Private []bool @@ -102,13 +107,14 @@ func TestFileAttr(t *testing.T) { }{ Type: SourceFileTypeCreate, TargetName: []string{}, + Empty: []bool{false, true}, Encrypted: []bool{false, true}, Executable: []bool{false, true}, Private: []bool{false, true}, ReadOnly: []bool{false, true}, Template: []bool{false, true}, })) - require.NoError(t, combinator.Generate(&fileAttrs, struct { + assert.NoError(t, combinator.Generate(&fileAttrs, struct { Type SourceFileTargetType TargetName []string Empty []bool @@ -127,9 +133,10 @@ func TestFileAttr(t *testing.T) { ReadOnly: []bool{false, true}, Template: []bool{false, true}, })) - require.NoError(t, combinator.Generate(&fileAttrs, struct { + assert.NoError(t, combinator.Generate(&fileAttrs, struct { Type SourceFileTargetType TargetName []string + Encrypted []bool Executable []bool Private []bool ReadOnly []bool @@ -137,30 +144,35 @@ func TestFileAttr(t *testing.T) { }{ Type: SourceFileTypeModify, TargetName: targetNames, + Encrypted: []bool{false, true}, Executable: []bool{false, true}, Private: []bool{false, true}, ReadOnly: []bool{false, true}, Template: []bool{false, true}, })) - require.NoError(t, combinator.Generate(&fileAttrs, struct { + assert.NoError(t, combinator.Generate(&fileAttrs, struct { Type SourceFileTargetType TargetName []string }{ Type: SourceFileTypeRemove, TargetName: targetNames, })) - require.NoError(t, combinator.Generate(&fileAttrs, struct { + assert.NoError(t, combinator.Generate(&fileAttrs, struct { Type SourceFileTargetType Condition []ScriptCondition TargetName []string Order []ScriptOrder }{ - Type: SourceFileTypeScript, - Condition: []ScriptCondition{ScriptConditionAlways, ScriptConditionOnce, ScriptConditionOnChange}, + Type: SourceFileTypeScript, + Condition: []ScriptCondition{ + ScriptConditionAlways, + ScriptConditionOnce, + ScriptConditionOnChange, + }, TargetName: targetNames, Order: []ScriptOrder{ScriptOrderBefore, ScriptOrderDuring, ScriptOrderAfter}, })) - require.NoError(t, combinator.Generate(&fileAttrs, struct { + assert.NoError(t, combinator.Generate(&fileAttrs, struct { Type SourceFileTargetType TargetName []string }{ @@ -238,6 +250,7 @@ func TestFileAttrLiteral(t *testing.T) { sourceName: "run_literal_once_script", fileAttr: FileAttr{ TargetName: "once_script", + Condition: ScriptConditionAlways, Type: SourceFileTypeScript, }, }, @@ -302,7 +315,7 @@ func TestFileAttrPerm(t *testing.T) { fileAttr: FileAttr{ Executable: true, }, - expected: 0o777, + expected: fs.ModePerm, }, { fileAttr: FileAttr{ diff --git a/internal/chezmoi/autotemplate.go b/internal/chezmoi/autotemplate.go index 5f43730bcb3..f6d8c11b1a7 100644 --- a/internal/chezmoi/autotemplate.go +++ b/internal/chezmoi/autotemplate.go @@ -1,6 +1,8 @@ package chezmoi import ( + "regexp" + "slices" "sort" "strings" ) @@ -8,47 +10,75 @@ import ( // 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 + components []string + value string } -// byValueLength implements sort.Interface for a slice of templateVariables, -// sorting by value length. -type byValueLength []templateVariable +var templateMarkerRx = regexp.MustCompile(`\{{2,}|\}{2,}`) -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 +// autoTemplate converts contents into a template by escaping template markers +// and replacing values in data with their keys. It returns the template and if +// any replacements were made. +func autoTemplate(contents []byte, data map[string]any) ([]byte, bool) { + contentsStr := string(contents) + replacements := false + + // Replace template markers. + replacedTemplateMarkersStr := templateMarkerRx.ReplaceAllString(contentsStr, `{{ "$0" }}`) + if replacedTemplateMarkersStr != contentsStr { + contentsStr = replacedTemplateMarkersStr + replacements = true } -} -func (b byValueLength) Swap(i, j int) { b[i], b[j] = b[j], b[i] } -// autoTemplate converts contents into a template by replacing values in data -// with their keys. It returns the template and if any replacements were made. -func autoTemplate(contents []byte, data map[string]interface{}) ([]byte, bool) { + // Determine the priority order of replacements. + // + // Replace longest values first. If there are multiple matches for the same + // length of value, then choose the shallowest first so that .variable is + // preferred over .chezmoi.config.data.variable. If there are multiple + // matches at the same depth, chose the variable that comes first + // alphabetically. + variables := extractVariables(data) + sort.Slice(variables, func(i, j int) bool { + // First sort by value length, longest first. + valueI := variables[i].value + valueJ := variables[j].value + switch { + case len(valueI) > len(valueJ): + return true + case len(valueI) == len(valueJ): + // Second sort by value name depth, shallowest first. + componentsI := variables[i].components + componentsJ := variables[j].components + switch { + case len(componentsI) < len(componentsJ): + return true + case len(componentsI) == len(componentsJ): + // Thirdly, sort by component names in alphabetical order. + return slices.Compare(componentsI, componentsJ) < 0 + default: + return false + } + default: + return false + } + }) + + // Replace variables in order. + // // 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) - replacements := false 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 + " }}" + replacement := "{{ ." + strings.Join(variable.components, ".") + " }}" contentsStr = contentsStr[:index] + replacement + contentsStr[index+len(variable.value):] index += len(replacement) replacements = true @@ -57,44 +87,44 @@ func autoTemplate(contents []byte, data map[string]interface{}) ([]byte, bool) { // 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 } + // Advance to the next occurrence. + index += j } } - return []byte(contentsStr), replacements -} -// extractVariables extracts all template variables from data. -func extractVariables(data map[string]interface{}) []templateVariable { - return extractVariablesHelper(nil /* variables */, nil /* parent */, data) + return []byte(contentsStr), replacements } -// extractVariablesHelper appends all template variables in data to variables +// appendVariables 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 { +func appendVariables(variables []templateVariable, parent []string, data map[string]any) []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) + variable := templateVariable{ + components: append(slices.Clone(parent), name), + value: value, + } + variables = append(variables, variable) + case map[string]any: + variables = appendVariables(variables, append(parent, name), value) } } return variables } +// extractVariables extracts all template variables from data. +func extractVariables(data map[string]any) []templateVariable { + return appendVariables(nil, nil, data) +} + // 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]) diff --git a/internal/chezmoi/autotemplate_test.go b/internal/chezmoi/autotemplate_test.go index ea538327335..b09cdfc48ae 100644 --- a/internal/chezmoi/autotemplate_test.go +++ b/internal/chezmoi/autotemplate_test.go @@ -3,21 +3,21 @@ package chezmoi import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/alecthomas/assert/v2" ) func TestAutoTemplate(t *testing.T) { for _, tc := range []struct { name string contentsStr string - data map[string]interface{} + data map[string]any expected string expectedReplacements bool }{ { name: "simple", contentsStr: "email = you@example.com\n", - data: map[string]interface{}{ + data: map[string]any{ "email": "you@example.com", }, expected: "email = {{ .email }}\n", @@ -26,7 +26,7 @@ func TestAutoTemplate(t *testing.T) { { name: "longest_first", contentsStr: "name = John Smith\nfirstName = John\n", - data: map[string]interface{}{ + data: map[string]any{ "name": "John Smith", "firstName": "John", }, @@ -38,7 +38,7 @@ func TestAutoTemplate(t *testing.T) { { name: "alphabetical_first", contentsStr: "name = John Smith\n", - data: map[string]interface{}{ + data: map[string]any{ "alpha": "John Smith", "beta": "John Smith", "gamma": "John Smith", @@ -49,8 +49,8 @@ func TestAutoTemplate(t *testing.T) { { name: "nested_values", contentsStr: "email = you@example.com\n", - data: map[string]interface{}{ - "personal": map[string]interface{}{ + data: map[string]any{ + "personal": map[string]any{ "email": "you@example.com", }, }, @@ -60,7 +60,7 @@ func TestAutoTemplate(t *testing.T) { { name: "only_replace_words", contentsStr: "darwinian evolution", - data: map[string]interface{}{ + data: map[string]any{ "os": "darwin", }, expected: "darwinian evolution", // not "{{ .os }}ian evolution" @@ -68,7 +68,7 @@ func TestAutoTemplate(t *testing.T) { { name: "longest_match_first", contentsStr: "/home/user", - data: map[string]interface{}{ + data: map[string]any{ "homeDir": "/home/user", }, expected: "{{ .homeDir }}", @@ -77,7 +77,7 @@ func TestAutoTemplate(t *testing.T) { { name: "longest_match_first_prefix", contentsStr: "HOME=/home/user", - data: map[string]interface{}{ + data: map[string]any{ "homeDir": "/home/user", }, expected: "HOME={{ .homeDir }}", @@ -86,7 +86,7 @@ func TestAutoTemplate(t *testing.T) { { name: "longest_match_first_suffix", contentsStr: "/home/user/something", - data: map[string]interface{}{ + data: map[string]any{ "homeDir": "/home/user", }, expected: "{{ .homeDir }}/something", @@ -95,16 +95,40 @@ func TestAutoTemplate(t *testing.T) { { name: "longest_match_first_prefix_and_suffix", contentsStr: "HOME=/home/user/something", - data: map[string]interface{}{ + data: map[string]any{ "homeDir": "/home/user", }, expected: "HOME={{ .homeDir }}/something", expectedReplacements: true, }, + { + name: "depth_first", + contentsStr: "a", + data: map[string]any{ + "deep": map[string]any{ + "deeper": "a", + }, + "shallow": "a", + }, + expected: "{{ .shallow }}", + expectedReplacements: true, + }, + { + name: "alphabetical_first", + contentsStr: "a", + data: map[string]any{ + "parent": map[string]any{ + "alpha": "a", + "beta": "a", + }, + }, + expected: "{{ .parent.alpha }}", + expectedReplacements: true, + }, { name: "words_only", contentsStr: "aaa aa a aa aaa aa a aa aaa", - data: map[string]interface{}{ + data: map[string]any{ "alpha": "a", }, expected: "aaa aa {{ .alpha }} aa aaa aa {{ .alpha }} aa aaa", @@ -113,7 +137,7 @@ func TestAutoTemplate(t *testing.T) { { name: "words_only_2", contentsStr: "aaa aa a aa aaa aa a aa aaa", - data: map[string]interface{}{ + data: map[string]any{ "alpha": "aa", }, expected: "aaa {{ .alpha }} a {{ .alpha }} aaa {{ .alpha }} a {{ .alpha }} aaa", @@ -122,7 +146,7 @@ func TestAutoTemplate(t *testing.T) { { name: "words_only_3", contentsStr: "aaa aa a aa aaa aa a aa aaa", - data: map[string]interface{}{ + data: map[string]any{ "alpha": "aaa", }, expected: "{{ .alpha }} aa a aa {{ .alpha }} aa a aa {{ .alpha }}", @@ -131,11 +155,17 @@ func TestAutoTemplate(t *testing.T) { { name: "skip_empty", contentsStr: "a", - data: map[string]interface{}{ + data: map[string]any{ "empty": "", }, expected: "a", }, + { + name: "markers", + contentsStr: "{{}}", + expected: `{{ "{{" }}{{ "}}" }}`, + expectedReplacements: true, + }, } { t.Run(tc.name, func(t *testing.T) { actualTemplate, actualReplacements := autoTemplate([]byte(tc.contentsStr), tc.data) diff --git a/internal/chezmoi/boltpersistentstate.go b/internal/chezmoi/boltpersistentstate.go index 9a7ab7d8849..5df90823742 100644 --- a/internal/chezmoi/boltpersistentstate.go +++ b/internal/chezmoi/boltpersistentstate.go @@ -2,8 +2,11 @@ package chezmoi import ( "errors" + "fmt" "io/fs" "os" + "slices" + "syscall" "time" "go.etcd.io/bbolt" @@ -86,7 +89,7 @@ func (b *BoltPersistentState) CopyTo(p PersistentState) error { 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)) + return p.Set(slices.Clone(bucket), slices.Clone(key), slices.Clone(value)) }) }) }) @@ -111,8 +114,22 @@ func (b *BoltPersistentState) Delete(bucket, key []byte) error { }) } +// DeleteBucket deletes the bucket. +func (b *BoltPersistentState) DeleteBucket(bucket []byte) error { + if b.empty { + return nil + } + if err := b.open(); err != nil { + return err + } + + return b.db.Update(func(tx *bbolt.Tx) error { + return tx.DeleteBucket(bucket) + }) +} + // Data returns all the data in b. -func (b *BoltPersistentState) Data() (interface{}, error) { +func (b *BoltPersistentState) Data() (any, error) { if b.empty { return nil, nil } @@ -156,7 +173,7 @@ func (b *BoltPersistentState) ForEach(bucket []byte, fn func(k, v []byte) error) return nil } return b.ForEach(func(k, v []byte) error { - return fn(copyByteSlice(k), copyByteSlice(v)) + return fn(slices.Clone(k), slices.Clone(v)) }) }) } @@ -176,7 +193,7 @@ func (b *BoltPersistentState) Get(bucket, key []byte) ([]byte, error) { if b == nil { return nil } - value = copyByteSlice(b.Get(key)) + value = slices.Clone(b.Get(key)) return nil }); err != nil { return nil, err @@ -205,24 +222,18 @@ func (b *BoltPersistentState) open() error { if b.db != nil { return nil } - if err := MkdirAll(b.system, b.path.Dir(), 0o777); err != nil { - return err - } - db, err := bbolt.Open(b.path.String(), 0o600, &b.options) - if err != nil { + if err := MkdirAll(b.system, b.path.Dir(), fs.ModePerm); err != nil { return err } - b.empty = false - b.db = db - return nil -} - -// copyByteSlice returns a copy of value. -func copyByteSlice(value []byte) []byte { - if value == nil { + switch db, err := bbolt.Open(b.path.String(), 0o600, &b.options); { + case errors.Is(err, syscall.EINVAL): + // Assume that any EINVAL error is because flock(2) failed. + return fmt.Errorf("open %s: failed to acquire lock: %w", b.path, err) + case err != nil: + return fmt.Errorf("open %s: %w", b.path, err) + default: + b.empty = false + b.db = db return nil } - result := make([]byte, len(value)) - copy(result, value) - return result } diff --git a/internal/chezmoi/boltpersistentstate_test.go b/internal/chezmoi/boltpersistentstate_test.go index 7e0a4b4896b..04c4fa7b3f8 100644 --- a/internal/chezmoi/boltpersistentstate_test.go +++ b/internal/chezmoi/boltpersistentstate_test.go @@ -1,12 +1,12 @@ package chezmoi import ( + "os" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -24,25 +24,25 @@ func TestBoltPersistentState(t *testing.T) { ) b1, err := NewBoltPersistentState(system, path, BoltPersistentStateReadWrite) - require.NoError(t, err) + assert.NoError(t, err) // Test that getting a key from an non-existent state does not create // the state. actualValue, err := b1.Get(bucket, key) - require.NoError(t, err) + assert.NoError(t, err) vfst.RunTests(t, fileSystem, "", vfst.TestPath(path.String(), - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), ) assert.Equal(t, []byte(nil), actualValue) // Test that deleting a key from a non-existent state does not create // the state. - require.NoError(t, b1.Delete(bucket, key)) + assert.NoError(t, b1.Delete(bucket, key)) vfst.RunTests(t, fileSystem, "", vfst.TestPath(path.String(), - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), ) @@ -50,39 +50,39 @@ func TestBoltPersistentState(t *testing.T) { assert.NoError(t, b1.Set(bucket, key, value)) vfst.RunTests(t, fileSystem, "", vfst.TestPath(path.String(), - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), ), ) actualValue, err = b1.Get(bucket, key) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, value, actualValue) visited := false - require.NoError(t, b1.ForEach(bucket, func(k, v []byte) error { + assert.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) + assert.True(t, visited) data, err := b1.Data() - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, map[string]map[string]string{ string(bucket): { string(key): string(value), }, - }, data) + }, data.(map[string]map[string]string)) - require.NoError(t, b1.Close()) + assert.NoError(t, b1.Close()) b2, err := NewBoltPersistentState(system, path, BoltPersistentStateReadWrite) - require.NoError(t, err) + assert.NoError(t, err) - require.NoError(t, b2.Delete(bucket, key)) + assert.NoError(t, b2.Delete(bucket, key)) actualValue, err = b2.Get(bucket, key) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, []byte(nil), actualValue) }) } @@ -99,33 +99,51 @@ func TestBoltPersistentStateMock(t *testing.T) { ) b, err := NewBoltPersistentState(system, path, BoltPersistentStateReadWrite) - require.NoError(t, err) - require.NoError(t, b.Set(bucket, key, value1)) + assert.NoError(t, err) + assert.NoError(t, b.Set(bucket, key, value1)) m := NewMockPersistentState() - require.NoError(t, b.CopyTo(m), err) + assert.NoError(t, b.CopyTo(m), err) actualValue, err := m.Get(bucket, key) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, value1, actualValue) - require.NoError(t, m.Set(bucket, key, value2)) + assert.NoError(t, m.Set(bucket, key, value2)) actualValue, err = m.Get(bucket, key) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, value2, actualValue) actualValue, err = b.Get(bucket, key) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, value1, actualValue) - require.NoError(t, m.Delete(bucket, key)) + assert.NoError(t, m.Delete(bucket, key)) actualValue, err = m.Get(bucket, key) - require.NoError(t, err) - assert.Nil(t, actualValue) + assert.NoError(t, err) + assert.Zero(t, actualValue) actualValue, err = b.Get(bucket, key) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, value1, actualValue) - require.NoError(t, b.Close()) + assert.NoError(t, b.Close()) + }) +} + +func TestBoltPersistentStateGeneric(t *testing.T) { + system := NewRealSystem(vfs.OSFS) + var tempDirs []string + defer func() { + for _, tempDir := range tempDirs { + assert.NoError(t, os.RemoveAll(tempDir)) + } + }() + testPersistentState(t, func() PersistentState { + tempDir, err := os.MkdirTemp("", "chezmoi-test") + assert.NoError(t, err) + absPath := NewAbsPath(tempDir).JoinString("chezmoistate.boltdb") + b, err := NewBoltPersistentState(system, absPath, BoltPersistentStateReadWrite) + assert.NoError(t, err) + return b }) } @@ -140,34 +158,34 @@ func TestBoltPersistentStateReadOnly(t *testing.T) { ) b1, err := NewBoltPersistentState(system, path, BoltPersistentStateReadWrite) - require.NoError(t, err) - require.NoError(t, b1.Set(bucket, key, value)) - require.NoError(t, b1.Close()) + assert.NoError(t, err) + assert.NoError(t, b1.Set(bucket, key, value)) + assert.NoError(t, b1.Close()) b2, err := NewBoltPersistentState(system, path, BoltPersistentStateReadOnly) - require.NoError(t, err) + assert.NoError(t, err) defer func() { assert.NoError(t, b2.Close()) }() b3, err := NewBoltPersistentState(system, path, BoltPersistentStateReadOnly) - require.NoError(t, err) + assert.NoError(t, err) defer func() { assert.NoError(t, b3.Close()) }() actualValueB, err := b2.Get(bucket, key) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, value, actualValueB) actualValueC, err := b3.Get(bucket, key) - require.NoError(t, err) + assert.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()) + assert.NoError(t, b2.Close()) + assert.NoError(t, b3.Close()) }) } diff --git a/internal/chezmoi/chezmoi.go b/internal/chezmoi/chezmoi.go index 1053c629371..809097a5da9 100644 --- a/internal/chezmoi/chezmoi.go +++ b/internal/chezmoi/chezmoi.go @@ -2,22 +2,32 @@ package chezmoi import ( + "bufio" "bytes" + "crypto/md5" //nolint:gosec + "crypto/sha1" //nolint:gosec "crypto/sha256" + "crypto/sha512" "fmt" "io/fs" - "path/filepath" + "net" + "os" "regexp" + "runtime" + "strconv" "strings" + + "github.com/spf13/cobra" + vfs "github.com/twpayne/go-vfs/v5" + "golang.org/x/crypto/ripemd160" //nolint:staticcheck + + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" ) var ( // DefaultTemplateOptions are the default template options. DefaultTemplateOptions = []string{"missingkey=error"} - // Skip indicates that entry should be skipped. - Skip = filepath.SkipDir - // Umask is the process's umask. Umask = fs.FileMode(0) ) @@ -33,6 +43,7 @@ const ( encryptedPrefix = "encrypted_" exactPrefix = "exact_" executablePrefix = "executable_" + externalPrefix = "external_" literalPrefix = "literal_" modifyPrefix = "modify_" oncePrefix = "once_" @@ -51,38 +62,65 @@ const ( Prefix = ".chezmoi" RootName = Prefix + "root" + TemplatesDirName = Prefix + "templates" + VersionName = Prefix + "version" dataName = Prefix + "data" externalName = Prefix + "external" + externalsDirName = Prefix + "externals" ignoreName = Prefix + "ignore" removeName = Prefix + "remove" - templatesDirName = Prefix + "templates" - versionName = Prefix + "version" + scriptsDirName = Prefix + "scripts" ) var ( - dirPrefixRegexp = regexp.MustCompile(`\A(dot|exact|literal|readonly|private)_`) - filePrefixRegexp = regexp.MustCompile( + dirPrefixRx = regexp.MustCompile(`\A(dot|exact|literal|readonly|private)_`) + filePrefixRx = regexp.MustCompile( `\A(after|before|create|dot|empty|encrypted|executable|literal|modify|once|private|readonly|remove|run|symlink)_`, ) - fileSuffixRegexp = regexp.MustCompile(`\.(literal|tmpl)\z`) + fileSuffixRx = regexp.MustCompile(`\.(literal|tmpl)\z`) + whitespaceRx = regexp.MustCompile(`\s+`) ) // knownPrefixedFiles is a set of known filenames with the .chezmoi prefix. -var knownPrefixedFiles = newStringSet( +var knownPrefixedFiles = chezmoiset.New( Prefix+".json"+TemplateSuffix, Prefix+".toml"+TemplateSuffix, Prefix+".yaml"+TemplateSuffix, RootName, - dataName, + VersionName, + dataName+".json", + dataName+".toml", + dataName+".yaml", + externalName+".json"+TemplateSuffix, externalName+".json", + externalName+".toml"+TemplateSuffix, externalName+".toml", + externalName+".yaml"+TemplateSuffix, externalName+".yaml", + ignoreName+TemplateSuffix, ignoreName, + removeName+TemplateSuffix, removeName, - versionName, ) -var modeTypeNames = map[fs.FileMode]string{ +// knownPrefixedDirs is a set of known dirnames with the .chezmoi prefix. +var knownPrefixedDirs = chezmoiset.New( + TemplatesDirName, + dataName, + externalsDirName, + scriptsDirName, +) + +// knownTargetFiles is a set of known target files that should not be managed +// directly. +var knownTargetFiles = chezmoiset.New( + "chezmoi.json", + "chezmoi.toml", + "chezmoi.yaml", + "chezmoistate.boltdb", +) + +var FileModeTypeNames = map[fs.FileMode]string{ 0: "file", fs.ModeDir: "dir", fs.ModeSymlink: "symlink", @@ -92,40 +130,60 @@ var modeTypeNames = map[fs.FileMode]string{ fs.ModeCharDevice: "char device", } -type inconsistentStateError struct { - targetRelPath RelPath - origins []string -} - -func (e *inconsistentStateError) Error() string { - return fmt.Sprintf("%s: inconsistent state (%s)", e.targetRelPath, strings.Join(e.origins, ", ")) -} +// FQDNHostname returns the FQDN hostname. +func FQDNHostname(fileSystem vfs.FS) (string, error) { + // First, try os.Hostname. If it returns something that looks like a FQDN + // hostname, or we're on Windows, return it. + osHostname, err := os.Hostname() + if runtime.GOOS == "windows" || (err == nil && strings.Contains(osHostname, ".")) { + return osHostname, err + } -type notInAbsDirError struct { - pathAbsPath AbsPath - dirAbsPath AbsPath -} + // Otherwise, if we're on OpenBSD, try /etc/myname. + if runtime.GOOS == "openbsd" { + if fqdnHostname, err := etcMynameFQDNHostname(fileSystem); err == nil && fqdnHostname != "" { + return fqdnHostname, nil + } + } -func (e *notInAbsDirError) Error() string { - return fmt.Sprintf("%s: not in %s", e.pathAbsPath, e.dirAbsPath) -} + // Otherwise, try /etc/hosts. + if fqdnHostname, err := etcHostsFQDNHostname(fileSystem); err == nil && fqdnHostname != "" { + return fqdnHostname, nil + } -type notInRelDirError struct { - pathRelPath RelPath - dirRelPath RelPath -} + // Otherwise, try /etc/hostname. + if fqdnHostname, err := etcHostnameFQDNHostname(fileSystem); err == nil && fqdnHostname != "" { + return fqdnHostname, nil + } -func (e *notInRelDirError) Error() string { - return fmt.Sprintf("%s: not in %s", e.pathRelPath, e.dirRelPath) + // Finally, fall back to whatever os.Hostname returned. + return osHostname, err } -type unsupportedFileTypeError struct { - absPath AbsPath - mode fs.FileMode +// FlagCompletionFunc returns a flag completion function. +func FlagCompletionFunc(allCompletions []string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var completions []string + for _, completion := range allCompletions { + if strings.HasPrefix(completion, toComplete) { + completions = append(completions, completion) + } + } + return completions, cobra.ShellCompDirectiveNoFileComp + } } -func (e *unsupportedFileTypeError) Error() string { - return fmt.Sprintf("%s: unsupported file type %s", e.absPath, modeTypeName(e.mode)) +// 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) + } } // SHA256Sum returns the SHA256 sum of data. @@ -135,12 +193,21 @@ func SHA256Sum(data []byte) []byte { } // SuspiciousSourceDirEntry returns true if base is a suspicious dir entry. -func SuspiciousSourceDirEntry(base string, fileInfo fs.FileInfo) bool { +func SuspiciousSourceDirEntry(base string, fileInfo fs.FileInfo, encryptedSuffixes []string) bool { switch fileInfo.Mode().Type() { case 0: - return strings.HasPrefix(base, Prefix) && !knownPrefixedFiles.contains(base) + if strings.HasPrefix(base, Prefix) && !knownPrefixedFiles.Contains(base) { + return true + } + for _, encryptedSuffix := range encryptedSuffixes { + fileAttr := parseFileAttr(fileInfo.Name(), encryptedSuffix) + if knownTargetFiles.Contains(fileAttr.TargetName) { + return true + } + } + return false case fs.ModeDir: - return strings.HasPrefix(base, Prefix) && base != templatesDirName + return strings.HasPrefix(base, Prefix) && !knownPrefixedDirs.Contains(base) case fs.ModeSymlink: return strings.HasPrefix(base, Prefix) default: @@ -148,36 +215,147 @@ func SuspiciousSourceDirEntry(base string, fileInfo fs.FileInfo) bool { } } +// UniqueAbbreviations returns a map of unique abbreviations of values to +// values. Values always map to themselves. +func UniqueAbbreviations(values []string) map[string]string { + abbreviations := make(map[string][]string) + for _, value := range values { + for i := 1; i <= len(value); i++ { + abbreviation := value[:i] + abbreviations[abbreviation] = append(abbreviations[abbreviation], value) + } + } + uniqueAbbreviations := make(map[string]string) + for abbreviation, values := range abbreviations { + if len(values) == 1 { + uniqueAbbreviations[abbreviation] = values[0] + } + } + for _, value := range values { + uniqueAbbreviations[value] = value + } + return uniqueAbbreviations +} + +// etcHostnameFQDNHostname returns the FQDN hostname from parsing /etc/hostname. +func etcHostnameFQDNHostname(fileSystem vfs.FS) (string, error) { + contents, err := fileSystem.ReadFile("/etc/hostname") + if err != nil { + return "", err + } + s := bufio.NewScanner(bytes.NewReader(contents)) + for s.Scan() { + text := s.Text() + text, _, _ = strings.Cut(text, "#") + if hostname := strings.TrimSpace(text); hostname != "" { + return hostname, nil + } + } + return "", s.Err() +} + +// etcMynameFQDNHostname returns the FQDN hostname from parsing /etc/myname. +// See OpenBSD's myname(5) for details on this file. +func etcMynameFQDNHostname(fileSystem vfs.FS) (string, error) { + contents, err := fileSystem.ReadFile("/etc/myname") + if err != nil { + return "", err + } + s := bufio.NewScanner(bytes.NewReader(contents)) + for s.Scan() { + text := s.Text() + if strings.HasPrefix(text, "#") { + continue + } + if hostname := strings.TrimSpace(text); hostname != "" { + return hostname, nil + } + } + return "", s.Err() +} + +// etcHostsFQDNHostname returns the FQDN hostname from parsing /etc/hosts. +func etcHostsFQDNHostname(fileSystem vfs.FS) (string, error) { + contents, err := fileSystem.ReadFile("/etc/hosts") + if err != nil { + return "", err + } + s := bufio.NewScanner(bytes.NewReader(contents)) + for s.Scan() { + text := s.Text() + text = strings.TrimSpace(text) + text, _, _ = strings.Cut(text, "#") + fields := whitespaceRx.Split(text, -1) + if len(fields) < 2 { + continue + } + if !net.ParseIP(fields[0]).IsLoopback() { + continue + } + hostname, domainname, found := strings.Cut(fields[1], ".") + if !found { + continue + } + if hostname == "localhost" { + continue + } + if domainname == "localdomain" { + continue + } + // Docker Desktop breaks /etc/hosts. Filter out all docker.internal + // domain names. See https://github.com/twpayne/chezmoi/issues/3095. + if domainname == "docker.internal" { + continue + } + if runtime.GOOS == "darwin" && domainname == "local" { + continue + } + return fields[1], nil + } + return "", s.Err() +} + // isEmpty returns true if data is empty after trimming whitespace from both // ends. func isEmpty(data []byte) bool { return len(bytes.TrimSpace(data)) == 0 } +// md5Sum returns the MD5 sum of data. +func md5Sum(data []byte) []byte { + md5SumArr := md5.Sum(data) //nolint:gosec + return md5SumArr[:] +} + // modeTypeName returns a string representation of mode. func modeTypeName(mode fs.FileMode) string { - if name, ok := modeTypeNames[mode.Type()]; ok { + if name, ok := FileModeTypeNames[mode.Type()]; ok { return name } return fmt.Sprintf("0o%o: unknown type", mode.Type()) } -// 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):] +// ripemd160Sum returns the RIPEMD-160 sum of data. +func ripemd160Sum(data []byte) []byte { + return ripemd160.New().Sum(data) } -// 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)] +// sha1Sum returns the SHA1 sum of data. +func sha1Sum(data []byte) []byte { + sha1SumArr := sha1.Sum(data) //nolint:gosec + return sha1SumArr[:] +} + +// sha384Sum returns the SHA384 sum of data. +func sha384Sum(data []byte) []byte { + sha384SumArr := sha512.Sum384(data) + return sha384SumArr[:] +} + +// sha512Sum returns the SHA512 sum of data. +func sha512Sum(data []byte) []byte { + sha512SumArr := sha512.Sum512(data) + return sha512SumArr[:] } // ensureSuffix adds suffix to s if s is not suffixed by suffix. diff --git a/internal/chezmoi/chezmoi_test.go b/internal/chezmoi/chezmoi_test.go index 858c016d295..9b76e605a97 100644 --- a/internal/chezmoi/chezmoi_test.go +++ b/internal/chezmoi/chezmoi_test.go @@ -1,17 +1,223 @@ package chezmoi import ( - "os" + "strings" + "testing" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/rs/zerolog/pkgerrors" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + + "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) -func init() { - log.Logger = log.Output(zerolog.ConsoleWriter{ - Out: os.Stderr, - NoColor: true, - }) - zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack +func TestEtcHostsFQDNHostname(t *testing.T) { + for _, tc := range []struct { + name string + root any + f func(vfs.FS) (string, error) + expected string + }{ + { + name: "etc_hosts", + root: map[string]any{ + "/etc/hosts": 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 host.example.com host`, + `192.168.1.10 foo.example.com foo`, + `192.168.1.13 bar.example.com bar`, + `146.82.138.7 master.debian.org master`, + `209.237.226.90 www.example.org`, + ``, + `# The following lines are desirable for IPv6 capable hosts`, + `::1 localhost ip6-localhost ip6-loopback`, + `ff02::1 ip6-allnodes`, + `ff02::2 ip6-allrouters`, + ), + }, + f: etcHostsFQDNHostname, + expected: "host.example.com", + }, + { + name: "etc_hosts_loopback_ipv4", + root: map[string]any{ + "/etc/hosts": chezmoitest.JoinLines( + `invalid localhost`, + `127.0.0.1 localhost`, + `::1 localhost`, + `127.0.0.2 host.example.com host`, + ), + }, + f: etcHostsFQDNHostname, + expected: "host.example.com", + }, + { + name: "etc_hosts_loopback_ipv4_localhost_dot_localdomain", + root: map[string]any{ + "/etc/hosts": chezmoitest.JoinLines( + `127.0.0.1 localhost.localdomain`, + `127.0.0.2 host.example.com host`, + ), + }, + f: etcHostsFQDNHostname, + expected: "host.example.com", + }, + { + name: "etc_hosts_loopback_ipv6", + root: map[string]any{ + "/etc/hosts": chezmoitest.JoinLines( + `127.0.0.1 localhost`, + `::1 localhost`, + `::1 host.example.com host`, + ), + }, + f: etcHostsFQDNHostname, + expected: "host.example.com", + }, + { + name: "etc_hosts_whitespace_and_comments", + root: map[string]any{ + "/etc/hosts": chezmoitest.JoinLines( + " \t127.0.1.1 \thost.example.com# comment", + ), + }, + f: etcHostsFQDNHostname, + expected: "host.example.com", + }, + { + name: "etc_hosts_missing_canonical_hostname", + root: map[string]any{ + "/etc/hosts": chezmoitest.JoinLines( + `127.0.1.1`, + `127.0.1.1 host.example.com`, + ), + }, + f: etcHostsFQDNHostname, + expected: "host.example.com", + }, + { + name: "etc_hosts_kubernetes_docker_internal", + root: map[string]any{ + "/etc/hosts": chezmoitest.JoinLines( + `##`, + `# Host Database`, + `#`, + `# localhost is used to configure the loopback interface`, + `# when the system is booting. Do not change this entry.`, + `##`, + `127.0.0.1 localhost`, + `255.255.255.255 broadcasthost`, + `::1 localhost`, + `# Added by Docker Desktop`, + `# To allow the same kube context to work on the host and the container:`, + `127.0.0.1 kubernetes.docker.internal`, + `# End of section`, + `127.0.1.1`, + `127.0.1.1 host.example.com`, + ), + }, + f: etcHostsFQDNHostname, + expected: "host.example.com", + }, + { + name: "etc_hostname", + root: map[string]any{ + "/etc/hostname": chezmoitest.JoinLines( + `# comment`, + ` hostname.example.com # comment`, + ), + }, + f: etcHostnameFQDNHostname, + expected: "hostname.example.com", + }, + { + name: "etc_myname", + root: map[string]any{ + "/etc/myname": chezmoitest.JoinLines( + "# comment", + "", + "hostname.example.com", + ), + }, + f: etcMynameFQDNHostname, + expected: "hostname.example.com", + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fileSystem vfs.FS) { + fqdnHostname, err := tc.f(fileSystem) + assert.NoError(t, err) + assert.Equal(t, tc.expected, fqdnHostname) + }) + }) + } +} + +func TestUniqueAbbreviations(t *testing.T) { + for _, tc := range []struct { + values []string + expected map[string]string + }{ + { + values: nil, + expected: map[string]string{}, + }, + { + values: []string{ + "yes", + "no", + "all", + "quit", + }, + expected: map[string]string{ + "y": "yes", + "ye": "yes", + "yes": "yes", + "n": "no", + "no": "no", + "a": "all", + "al": "all", + "all": "all", + "q": "quit", + "qu": "quit", + "qui": "quit", + "quit": "quit", + }, + }, + { + values: []string{ + "ale", + "all", + "abort", + }, + expected: map[string]string{ + "ale": "ale", + "all": "all", + "ab": "abort", + "abo": "abort", + "abor": "abort", + "abort": "abort", + }, + }, + { + values: []string{ + "no", + "now", + "nope", + }, + expected: map[string]string{ + "no": "no", + "now": "now", + "nop": "nope", + "nope": "nope", + }, + }, + } { + t.Run(strings.Join(tc.values, "_"), func(t *testing.T) { + actual := UniqueAbbreviations(tc.values) + assert.Equal(t, tc.expected, actual) + }) + } } diff --git a/internal/chezmoi/chezmoi_unix.go b/internal/chezmoi/chezmoi_unix.go index 653e5f80f0c..e4033b44afe 100644 --- a/internal/chezmoi/chezmoi_unix.go +++ b/internal/chezmoi/chezmoi_unix.go @@ -1,80 +1,35 @@ -//go:build !windows -// +build !windows +//go:build unix package chezmoi import ( - "bufio" - "bytes" "io/fs" - "regexp" - "strings" + "os" - vfs "github.com/twpayne/go-vfs/v4" "golang.org/x/sys/unix" ) -var whitespaceRx = regexp.MustCompile(`\s+`) +const nativeLineEnding = "\n" func init() { Umask = fs.FileMode(unix.Umask(0)) unix.Umask(int(Umask)) } -// FQDNHostname returns the FQDN hostname, if it can be determined. -func FQDNHostname(fileSystem vfs.FS) string { - if fqdnHostname, err := etcHostsFQDNHostname(fileSystem); err == nil && fqdnHostname != "" { - return fqdnHostname - } - if fqdnHostname, err := etcHostnameFQDNHostname(fileSystem); err == nil && fqdnHostname != "" { - return fqdnHostname - } - return "" +// findExecutableExtensions returns valid OS executable extensions, on unix it +// can be anything. +func findExecutableExtensions(path string) []string { + return []string{path} } -// etcHostnameFQDNHostname returns the FQDN hostname from parsing /etc/hostname. -func etcHostnameFQDNHostname(fileSystem vfs.FS) (string, error) { - contents, err := fileSystem.ReadFile("/etc/hostname") - if err != nil { - return "", err - } - s := bufio.NewScanner(bytes.NewReader(contents)) - for s.Scan() { - text := s.Text() - if index := strings.IndexByte(text, '#'); index != -1 { - text = text[:index] - } - if hostname := strings.TrimSpace(text); hostname != "" { - return hostname, nil - } - } - return "", s.Err() -} - -// etcHostsFQDNHostname returns the FQDN hostname from parsing /etc/hosts. -func etcHostsFQDNHostname(fileSystem vfs.FS) (string, error) { - contents, err := fileSystem.ReadFile("/etc/hosts") - if err != nil { - return "", err - } - s := bufio.NewScanner(bytes.NewReader(contents)) - 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 fileInfo is executable. +func IsExecutable(fileInfo fs.FileInfo) bool { + return fileInfo.Mode().Perm()&0o111 != 0 } -// isExecutable returns if fileInfo is executable. -func isExecutable(fileInfo fs.FileInfo) bool { - return fileInfo.Mode().Perm()&0o111 != 0 +// UserHomeDir on UNIX returns the value of os.UserHomeDir. +func UserHomeDir() (string, error) { + return os.UserHomeDir() } // isPrivate returns if fileInfo is private. diff --git a/internal/chezmoi/chezmoi_unix_test.go b/internal/chezmoi/chezmoi_unix_test.go index db10e03d5f0..809900f6ee9 100644 --- a/internal/chezmoi/chezmoi_unix_test.go +++ b/internal/chezmoi/chezmoi_unix_test.go @@ -1,95 +1,15 @@ -//go:build !windows -// +build !windows +//go:build unix package chezmoi import ( "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - vfs "github.com/twpayne/go-vfs/v4" + "github.com/alecthomas/assert/v2" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) -func TestFQDNHostname(t *testing.T) { - for _, tc := range []struct { - name string - root interface{} - expected string - }{ - { - name: "empty", - }, - { - name: "etc_hosts", - root: map[string]interface{}{ - "/etc/hosts": 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: "etc_hosts_whitespace_and_comments", - root: map[string]interface{}{ - "/etc/hosts": chezmoitest.JoinLines( - " \t127.0.1.1 \tthishost.mydomain.org# comment", - ), - }, - expected: "thishost.mydomain.org", - }, - { - name: "etc_hosts_missing_canonical_hostname", - root: map[string]interface{}{ - "/etc/hosts": chezmoitest.JoinLines( - `127.0.1.1`, - `127.0.1.1 thishost.mydomain.org`, - ), - }, - expected: "thishost.mydomain.org", - }, - { - name: "etc_hostname", - root: map[string]interface{}{ - "/etc/hostname": chezmoitest.JoinLines( - `# comment`, - ` hostname.example.com # comment`, - ), - }, - expected: "hostname.example.com", - }, - { - name: "etc_hosts_and_etc_hostname", - root: map[string]interface{}{ - "/etc/hosts": "127.0.1.1 hostname.example.com hostname\n", - "/etc/hostname": "hostname\n", - }, - expected: "hostname.example.com", - }, - } { - t.Run(tc.name, func(t *testing.T) { - chezmoitest.WithTestFS(t, tc.root, func(fileSystem vfs.FS) { - assert.Equal(t, tc.expected, FQDNHostname(fileSystem)) - }) - }) - } -} - func TestUmask(t *testing.T) { - require.Equal(t, chezmoitest.Umask, Umask) + assert.Equal(t, chezmoitest.Umask, Umask) } diff --git a/internal/chezmoi/chezmoi_windows.go b/internal/chezmoi/chezmoi_windows.go index d350e77373a..f196e3fc9c7 100644 --- a/internal/chezmoi/chezmoi_windows.go +++ b/internal/chezmoi/chezmoi_windows.go @@ -1,42 +1,67 @@ package chezmoi import ( - "errors" "io/fs" - "unicode/utf16" - - vfs "github.com/twpayne/go-vfs/v4" - "golang.org/x/sys/windows" + "os" + "path/filepath" + "slices" + "strings" ) -// FQDNHostname returns the machine's fully-qualified DNS domain name, if -// available. -func FQDNHostname(fileSystem vfs.FS) string { - n := uint32(windows.MAX_COMPUTERNAME_LENGTH + 1) - buf := make([]uint16, n) - err := windows.GetComputerNameEx(windows.ComputerNameDnsFullyQualified, &buf[0], &n) - if errors.Is(err, windows.ERROR_MORE_DATA) { - buf = make([]uint16, n) - err = windows.GetComputerNameEx(windows.ComputerNameDnsFullyQualified, &buf[0], &n) +const nativeLineEnding = "\r\n" + +var pathExts = strings.Split(os.Getenv("PATHEXT"), string(filepath.ListSeparator)) + +// findExecutableExtensions returns valid OS executable extensions for the +// provided file if it does not already have an extension. The executable +// extensions are derived from %PathExt%. +func findExecutableExtensions(path string) []string { + cmdExt := filepath.Ext(path) + if cmdExt != "" { + return []string{path} } - if err != nil { - return "" + result := make([]string, len(pathExts)) + withoutSuffix := strings.TrimSuffix(path, cmdExt) + for i, ext := range pathExts { + result[i] = withoutSuffix + ext } - return string(utf16.Decode(buf[0:n])) + return result } -// isExecutable returns false on Windows. -func isExecutable(fileInfo fs.FileInfo) bool { - return false +// IsExecutable checks if the file is a regular file and has an extension listed +// in the PATHEXT environment variable as per +// https://www.nextofwindows.com/what-is-pathext-environment-variable-in-windows. +func IsExecutable(fileInfo fs.FileInfo) bool { + if !fileInfo.Mode().IsRegular() { + return false + } + ext := filepath.Ext(fileInfo.Name()) + if ext == "" { + return false + } + return slices.ContainsFunc(pathExts, func(pathExt string) bool { + return strings.EqualFold(pathExt, ext) + }) +} + +// UserHomeDir on Windows returns the value of $HOME if it is set and either +// Cygwin or msys2 is detected, otherwise it falls back to os.UserHomeDir. +func UserHomeDir() (string, error) { + if os.Getenv("CYGWIN") != "" || os.Getenv("MSYSTEM") != "" { + if userHomeDir := os.Getenv("HOME"); userHomeDir != "" { + return userHomeDir, nil + } + } + return os.UserHomeDir() } // isPrivate returns false on Windows. -func isPrivate(fileInfo fs.FileInfo) bool { +func isPrivate(_ fs.FileInfo) bool { return false } // isReadOnly returns false on Windows. -func isReadOnly(fileInfo fs.FileInfo) bool { +func isReadOnly(_ fs.FileInfo) bool { return false } diff --git a/internal/chezmoi/compression.go b/internal/chezmoi/compression.go new file mode 100644 index 00000000000..51f8ca8aceb --- /dev/null +++ b/internal/chezmoi/compression.go @@ -0,0 +1,53 @@ +package chezmoi + +import ( + "bytes" + "compress/bzip2" + "fmt" + "io" + + "github.com/klauspost/compress/gzip" + "github.com/klauspost/compress/zstd" + "github.com/ulikunitz/xz" +) + +// A compressionFormat is a compression format. +type compressionFormat string + +// Compression formats. +const ( + compressionFormatNone compressionFormat = "" + compressionFormatBzip2 compressionFormat = "bzip2" + compressionFormatGzip compressionFormat = "gzip" + compressionFormatXz compressionFormat = "xz" + compressionFormatZstd compressionFormat = "zstd" +) + +func decompress(compressionFormat compressionFormat, data []byte) ([]byte, error) { + switch compressionFormat { + case compressionFormatNone: + return data, nil + case compressionFormatBzip2: + return io.ReadAll(bzip2.NewReader(bytes.NewReader(data))) + case compressionFormatGzip: + gzipReader, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, err + } + return io.ReadAll(gzipReader) + case compressionFormatXz: + xzReader, err := xz.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, err + } + return io.ReadAll(xzReader) + case compressionFormatZstd: + zstdReader, err := zstd.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, err + } + return io.ReadAll(zstdReader) + default: + return nil, fmt.Errorf("%s: unknown compression format", compressionFormat) + } +} diff --git a/internal/chezmoi/data.go b/internal/chezmoi/data.go index 75901ec5480..558fb573008 100644 --- a/internal/chezmoi/data.go +++ b/internal/chezmoi/data.go @@ -12,11 +12,11 @@ import ( "strings" "unicode" - "github.com/twpayne/go-vfs/v4" + "github.com/twpayne/go-vfs/v5" ) // Kernel returns the kernel information parsed from /proc/sys/kernel. -func Kernel(fileSystem vfs.FS) (map[string]interface{}, error) { +func Kernel(fileSystem vfs.FS) (map[string]any, error) { const procSysKernel = "/proc/sys/kernel" switch fileInfo, err := fileSystem.Stat(procSysKernel); { @@ -30,7 +30,7 @@ func Kernel(fileSystem vfs.FS) (map[string]interface{}, error) { return nil, nil } - kernel := make(map[string]interface{}) + kernel := make(map[string]any) for _, filename := range []string{ "osrelease", "ostype", @@ -52,12 +52,12 @@ func Kernel(fileSystem vfs.FS) (map[string]interface{}, error) { // OSRelease returns the operating system identification data as defined by the // os-release specification. -func OSRelease(system System) (map[string]interface{}, error) { - for _, filename := range []AbsPath{ - NewAbsPath("/etc/os-release"), - NewAbsPath("/usr/lib/os-release"), +func OSRelease(fileSystem vfs.FS) (map[string]any, error) { + for _, filename := range []string{ + "/etc/os-release", + "/usr/lib/os-release", } { - data, err := system.ReadFile(filename) + data, err := fileSystem.ReadFile(filename) if errors.Is(err, fs.ErrNotExist) { continue } else if err != nil { @@ -84,8 +84,8 @@ func maybeUnquote(s string) string { // parseOSRelease parses operating system identification data from r as defined // by the os-release specification. -func parseOSRelease(r io.Reader) (map[string]interface{}, error) { - result := make(map[string]interface{}) +func parseOSRelease(r io.Reader) (map[string]any, error) { + result := make(map[string]any) s := bufio.NewScanner(r) for s.Scan() { // Trim all leading whitespace, but not necessarily trailing whitespace. @@ -94,13 +94,11 @@ func parseOSRelease(r io.Reader) (map[string]interface{}, error) { if len(token) == 0 || token[0] == '#' { continue } - fields := strings.SplitN(token, "=", 2) - if len(fields) != 2 { + key, value, ok := strings.Cut(token, "=") + if !ok { return nil, fmt.Errorf("%s: parse error", token) } - key := fields[0] - value := maybeUnquote(fields[1]) - result[key] = value + result[key] = maybeUnquote(value) } return result, s.Err() } diff --git a/internal/chezmoi/data_test.go b/internal/chezmoi/data_test.go index 30055bcdbcf..7a67d0d689f 100644 --- a/internal/chezmoi/data_test.go +++ b/internal/chezmoi/data_test.go @@ -4,9 +4,9 @@ import ( "bytes" "testing" - "github.com/stretchr/testify/assert" - "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -14,19 +14,19 @@ import ( func TestKernel(t *testing.T) { for _, tc := range []struct { name string - root interface{} - expectedKernel map[string]interface{} + root any + expectedKernel map[string]any }{ { name: "windows_services_for_linux", - root: map[string]interface{}{ - "/proc/sys/kernel": map[string]interface{}{ + root: map[string]any{ + "/proc/sys/kernel": map[string]any{ "osrelease": "4.19.81-microsoft-standard\n", "ostype": "Linux\n", "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)\n", }, }, - expectedKernel: map[string]interface{}{ + expectedKernel: map[string]any{ "osrelease": "4.19.81-microsoft-standard", "ostype": "Linux", "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)", @@ -34,18 +34,18 @@ func TestKernel(t *testing.T) { }, { name: "debian_version_only", - root: map[string]interface{}{ - "/proc/sys/kernel": map[string]interface{}{ + root: map[string]any{ + "/proc/sys/kernel": map[string]any{ "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)\n", }, }, - expectedKernel: map[string]interface{}{ + expectedKernel: map[string]any{ "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)", }, }, { name: "proc_sys_kernel_missing", - root: map[string]interface{}{ + root: map[string]any{ "/proc/sys": &vfst.Dir{Perm: 0o755}, }, expectedKernel: nil, @@ -64,12 +64,12 @@ func TestKernel(t *testing.T) { func TestOSRelease(t *testing.T) { for _, tc := range []struct { name string - root map[string]interface{} - expected map[string]interface{} + root map[string]any + expected map[string]any }{ { name: "archlinux", - root: map[string]interface{}{ + root: map[string]any{ "/usr/lib/os-release": chezmoitest.JoinLines( `NAME="Arch Linux"`, `PRETTY_NAME="Arch Linux"`, @@ -83,7 +83,7 @@ func TestOSRelease(t *testing.T) { `LOGO=archlinux`, ), }, - expected: map[string]interface{}{ + expected: map[string]any{ "NAME": "Arch Linux", "PRETTY_NAME": "Arch Linux", "ID": "arch", @@ -98,7 +98,7 @@ func TestOSRelease(t *testing.T) { }, { name: "fedora", - root: map[string]interface{}{ + root: map[string]any{ "/etc/os-release": chezmoitest.JoinLines( `NAME=Fedora`, `VERSION="17 (Beefy Miracle)"`, @@ -111,7 +111,7 @@ func TestOSRelease(t *testing.T) { `BUG_REPORT_URL="https://bugzilla.redhat.com/"`, ), }, - expected: map[string]interface{}{ + expected: map[string]any{ "NAME": "Fedora", "VERSION": "17 (Beefy Miracle)", "ID": "fedora", @@ -125,7 +125,7 @@ func TestOSRelease(t *testing.T) { }, { name: "ubuntu", - root: map[string]interface{}{ + root: map[string]any{ "/usr/lib/os-release": chezmoitest.JoinLines( `NAME="Ubuntu"`, `VERSION="18.04.1 LTS (Bionic Beaver)"`, @@ -141,7 +141,7 @@ func TestOSRelease(t *testing.T) { `UBUNTU_CODENAME=bionic`, ), }, - expected: map[string]interface{}{ + expected: map[string]any{ "NAME": "Ubuntu", "VERSION": "18.04.1 LTS (Bionic Beaver)", "ID": "ubuntu", @@ -159,8 +159,7 @@ func TestOSRelease(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { chezmoitest.WithTestFS(t, tc.root, func(fileSystem vfs.FS) { - system := NewRealSystem(fileSystem) - actual, err := OSRelease(system) + actual, err := OSRelease(fileSystem) assert.NoError(t, err) assert.Equal(t, tc.expected, actual) }) @@ -172,7 +171,7 @@ func TestParseOSRelease(t *testing.T) { for _, tc := range []struct { name string s string - expected map[string]interface{} + expected map[string]any }{ { name: "fedora", @@ -187,7 +186,7 @@ func TestParseOSRelease(t *testing.T) { `HOME_URL="https://fedoraproject.org/"`, `BUG_REPORT_URL="https://bugzilla.redhat.com/"`, ), - expected: map[string]interface{}{ + expected: map[string]any{ "NAME": "Fedora", "VERSION": "17 (Beefy Miracle)", "ID": "fedora", @@ -218,7 +217,7 @@ func TestParseOSRelease(t *testing.T) { `VERSION_CODENAME=bionic`, `UBUNTU_CODENAME=bionic`, ), - expected: map[string]interface{}{ + expected: map[string]any{ "NAME": "Ubuntu", "VERSION": "18.04.1 LTS (Bionic Beaver)", "ID": "ubuntu", diff --git a/internal/chezmoi/debugencryption.go b/internal/chezmoi/debugencryption.go index 892f883d86c..262113bd664 100644 --- a/internal/chezmoi/debugencryption.go +++ b/internal/chezmoi/debugencryption.go @@ -1,20 +1,20 @@ package chezmoi import ( - "github.com/rs/zerolog" + "log/slog" "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) // A DebugEncryption logs all calls to an Encryption. type DebugEncryption struct { - logger *zerolog.Logger + logger *slog.Logger encryption Encryption } // NewDebugEncryption returns a new DebugEncryption that logs methods on // encryption to logger. -func NewDebugEncryption(encryption Encryption, logger *zerolog.Logger) *DebugEncryption { +func NewDebugEncryption(encryption Encryption, logger *slog.Logger) *DebugEncryption { return &DebugEncryption{ logger: logger, encryption: encryption, @@ -24,40 +24,40 @@ func NewDebugEncryption(encryption Encryption, logger *zerolog.Logger) *DebugEnc // Decrypt implements Encryption.Decrypt. func (e *DebugEncryption) Decrypt(ciphertext []byte) ([]byte, error) { plaintext, err := e.encryption.Decrypt(ciphertext) - e.logger.Err(err). - Bytes("ciphertext", chezmoilog.Output(ciphertext, err)). - Bytes("plaintext", chezmoilog.Output(plaintext, err)). - Msg("Decrypt") + chezmoilog.InfoOrError(e.logger, "Decrypt", err, + chezmoilog.FirstFewBytes("ciphertext", ciphertext), + chezmoilog.FirstFewBytes("plaintext", plaintext), + ) return plaintext, err } // DecryptToFile implements Encryption.DecryptToFile. func (e *DebugEncryption) DecryptToFile(plaintextAbsPath AbsPath, ciphertext []byte) error { err := e.encryption.DecryptToFile(plaintextAbsPath, ciphertext) - e.logger.Err(err). - Stringer("plaintextAbsPath", plaintextAbsPath). - Bytes("ciphertext", chezmoilog.Output(ciphertext, err)). - Msg("DecryptToFile") + chezmoilog.InfoOrError(e.logger, "DecryptToFile", err, + chezmoilog.Stringer("plaintextAbsPath", plaintextAbsPath), + chezmoilog.FirstFewBytes("ciphertext", ciphertext), + ) return err } // Encrypt implements Encryption.Encrypt. func (e *DebugEncryption) Encrypt(plaintext []byte) ([]byte, error) { ciphertext, err := e.encryption.Encrypt(plaintext) - e.logger.Err(err). - Bytes("plaintext", chezmoilog.Output(plaintext, err)). - Bytes("ciphertext", chezmoilog.Output(ciphertext, err)). - Msg("Encrypt") + chezmoilog.InfoOrError(e.logger, "Encrypt", err, + chezmoilog.FirstFewBytes("plaintext", plaintext), + chezmoilog.FirstFewBytes("ciphertext", ciphertext), + ) return ciphertext, err } // EncryptFile implements Encryption.EncryptFile. func (e *DebugEncryption) EncryptFile(plaintextAbsPath AbsPath) ([]byte, error) { ciphertext, err := e.encryption.EncryptFile(plaintextAbsPath) - e.logger.Err(err). - Stringer("plaintextAbsPath", plaintextAbsPath). - Bytes("ciphertext", chezmoilog.Output(ciphertext, err)). - Msg("EncryptFile") + chezmoilog.InfoOrError(e.logger, "EncryptFile", err, + chezmoilog.Stringer("plaintextAbsPath", plaintextAbsPath), + chezmoilog.FirstFewBytes("ciphertext", ciphertext), + ) return ciphertext, err } diff --git a/internal/chezmoi/debugpersistentstate.go b/internal/chezmoi/debugpersistentstate.go index 614a3db5b08..3b7b99e6bf5 100644 --- a/internal/chezmoi/debugpersistentstate.go +++ b/internal/chezmoi/debugpersistentstate.go @@ -1,18 +1,20 @@ package chezmoi import ( - "github.com/rs/zerolog" + "log/slog" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) // A DebugPersistentState logs calls to a PersistentState. type DebugPersistentState struct { - logger *zerolog.Logger + logger *slog.Logger persistentState PersistentState } // NewDebugPersistentState returns a new debugPersistentState that logs methods // on persistentState to logger. -func NewDebugPersistentState(persistentState PersistentState, logger *zerolog.Logger) *DebugPersistentState { +func NewDebugPersistentState(persistentState PersistentState, logger *slog.Logger) *DebugPersistentState { return &DebugPersistentState{ logger: logger, persistentState: persistentState, @@ -22,35 +24,42 @@ func NewDebugPersistentState(persistentState PersistentState, logger *zerolog.Lo // Close implements PersistentState.Close. func (s *DebugPersistentState) Close() error { err := s.persistentState.Close() - s.logger.Err(err). - Msg("Close") + chezmoilog.InfoOrError(s.logger, "Close", err) return err } // CopyTo implements PersistentState.CopyTo. func (s *DebugPersistentState) CopyTo(p PersistentState) error { err := s.persistentState.CopyTo(p) - s.logger.Err(err). - Msg("CopyTo") + chezmoilog.InfoOrError(s.logger, "CopyTo", err) return err } // Data implements PersistentState.Data. -func (s *DebugPersistentState) Data() (interface{}, error) { +func (s *DebugPersistentState) Data() (any, error) { data, err := s.persistentState.Data() - s.logger.Err(err). - Interface("data", data). - Msg("Data") + chezmoilog.InfoOrError(s.logger, "Data", err, + slog.Any("data", data), + ) return data, err } // Delete implements PersistentState.Delete. func (s *DebugPersistentState) Delete(bucket, key []byte) error { err := s.persistentState.Delete(bucket, key) - s.logger.Err(err). - Bytes("bucket", bucket). - Bytes("key", key). - Msg("Delete") + chezmoilog.InfoOrError(s.logger, "Delete", err, + chezmoilog.Bytes("bucket", bucket), + chezmoilog.Bytes("key", key), + ) + return err +} + +// DeleteBucket implements PersistentState.DeleteBucket. +func (s *DebugPersistentState) DeleteBucket(bucket []byte) error { + err := s.persistentState.DeleteBucket(bucket) + chezmoilog.InfoOrError(s.logger, "DeleteBucket", err, + chezmoilog.Bytes("bucket", bucket), + ) return err } @@ -58,37 +67,37 @@ func (s *DebugPersistentState) Delete(bucket, key []byte) error { 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) - s.logger.Err(err). - Bytes("bucket", bucket). - Bytes("key", k). - Bytes("value", v). - Msg("ForEach") + chezmoilog.InfoOrError(s.logger, "ForEach", err, + chezmoilog.Bytes("bucket", bucket), + chezmoilog.Bytes("key", k), + chezmoilog.Bytes("value", v), + ) return err }) - s.logger.Err(err). - Bytes("bucket", bucket). - Msg("ForEach") + chezmoilog.InfoOrError(s.logger, "ForEach", err, + chezmoilog.Bytes("bucket", bucket), + ) return err } // Get implements PersistentState.Get. func (s *DebugPersistentState) Get(bucket, key []byte) ([]byte, error) { value, err := s.persistentState.Get(bucket, key) - s.logger.Err(err). - Bytes("bucket", bucket). - Bytes("key", key). - Bytes("value", value). - Msg("Get") + chezmoilog.InfoOrError(s.logger, "Get", err, + chezmoilog.Bytes("bucket", bucket), + chezmoilog.Bytes("key", key), + chezmoilog.Bytes("value", value), + ) return value, err } // Set implements PersistentState.Set. func (s *DebugPersistentState) Set(bucket, key, value []byte) error { err := s.persistentState.Set(bucket, key, value) - s.logger.Err(err). - Bytes("bucket", bucket). - Bytes("key", key). - Bytes("value", value). - Msg("Set") + chezmoilog.InfoOrError(s.logger, "Set", err, + chezmoilog.Bytes("bucket", bucket), + chezmoilog.Bytes("key", key), + chezmoilog.Bytes("value", value), + ) return err } diff --git a/internal/chezmoi/debugsystem.go b/internal/chezmoi/debugsystem.go index 360811766a4..ab540ab47f0 100644 --- a/internal/chezmoi/debugsystem.go +++ b/internal/chezmoi/debugsystem.go @@ -2,96 +2,86 @@ package chezmoi import ( "io/fs" + "log/slog" "os/exec" + "time" - "github.com/rs/zerolog" - vfs "github.com/twpayne/go-vfs/v4" + "github.com/twpayne/go-vfs/v5" "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) // A DebugSystem logs all calls to a System. type DebugSystem struct { - logger *zerolog.Logger + logger *slog.Logger system System } // NewDebugSystem returns a new DebugSystem that logs methods on system to logger. -func NewDebugSystem(system System, logger *zerolog.Logger) *DebugSystem { +func NewDebugSystem(system System, logger *slog.Logger) *DebugSystem { return &DebugSystem{ logger: logger, system: system, } } +// Chtimes implements System.Chtimes. +func (s *DebugSystem) Chtimes(name AbsPath, atime, mtime time.Time) error { + err := s.system.Chtimes(name, atime, mtime) + chezmoilog.InfoOrError(s.logger, "Chtimes", err, + chezmoilog.Stringer("name", name), + slog.Time("atime", atime), + slog.Time("mtime", mtime), + ) + return err +} + // Chmod implements System.Chmod. func (s *DebugSystem) Chmod(name AbsPath, mode fs.FileMode) error { err := s.system.Chmod(name, mode) - s.logger.Err(err). - Stringer("name", name). - Int("mode", int(mode)). - Msg("Chmod") + chezmoilog.InfoOrError(s.logger, "Chmod", err, + chezmoilog.Stringer("name", name), + slog.Int("mode", int(mode)), + ) return err } // Glob implements System.Glob. func (s *DebugSystem) Glob(name string) ([]string, error) { matches, err := s.system.Glob(name) - s.logger.Err(err). - Str("name", name). - Strs("matches", matches). - Msg("Glob") + chezmoilog.InfoOrError(s.logger, "Glob", err, + slog.String("name", name), + slog.Any("matches", matches), + ) return matches, err } -// IdempotentCmdCombinedOutput implements System.IdempotentCmdCombinedOutput. -func (s *DebugSystem) IdempotentCmdCombinedOutput(cmd *exec.Cmd) ([]byte, error) { - output, err := s.system.IdempotentCmdCombinedOutput(cmd) - s.logger.Err(err). - EmbedObject(chezmoilog.OSExecCmdLogObject{Cmd: cmd}). - Bytes("output", chezmoilog.Output(output, err)). - EmbedObject(chezmoilog.OSExecExitErrorLogObject{Err: err}). - Msg("IdempotentCmdCombinedOutput") - return output, err -} - -// IdempotentCmdOutput implements System.IdempotentCmdOutput. -func (s *DebugSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { - output, err := s.system.IdempotentCmdOutput(cmd) - s.logger.Err(err). - EmbedObject(chezmoilog.OSExecCmdLogObject{Cmd: cmd}). - Bytes("output", chezmoilog.Output(output, err)). - EmbedObject(chezmoilog.OSExecExitErrorLogObject{Err: err}). - Msg("IdempotentCmdOutput") - return output, err -} - // Link implements System.Link. -func (s *DebugSystem) Link(oldpath, newpath AbsPath) error { - err := s.system.Link(oldpath, newpath) - s.logger.Err(err). - Stringer("oldpath", oldpath). - Stringer("newpath", newpath). - Msg("Link") +func (s *DebugSystem) Link(oldPath, newPath AbsPath) error { + err := s.system.Link(oldPath, newPath) + chezmoilog.InfoOrError(s.logger, "Link", err, + chezmoilog.Stringer("oldPath", oldPath), + chezmoilog.Stringer("newPath", newPath), + ) return err } // Lstat implements System.Lstat. func (s *DebugSystem) Lstat(name AbsPath) (fs.FileInfo, error) { fileInfo, err := s.system.Lstat(name) - s.logger.Err(err). - Stringer("name", name). - Msg("Lstat") + chezmoilog.InfoOrError(s.logger, "Lstat", err, + chezmoilog.Stringer("name", name), + ) return fileInfo, err } // Mkdir implements System.Mkdir. func (s *DebugSystem) Mkdir(name AbsPath, perm fs.FileMode) error { err := s.system.Mkdir(name, perm) - s.logger.Err(err). - Stringer("name", name). - Int("perm", int(perm)). - Msg("Mkdir") + chezmoilog.InfoOrError(s.logger, "Mkdir", err, + chezmoilog.Stringer("name", name), + slog.Int("perm", int(perm)), + ) return err } @@ -103,90 +93,94 @@ func (s *DebugSystem) RawPath(path AbsPath) (AbsPath, error) { // ReadDir implements System.ReadDir. func (s *DebugSystem) ReadDir(name AbsPath) ([]fs.DirEntry, error) { dirEntries, err := s.system.ReadDir(name) - s.logger.Err(err). - Stringer("name", name). - Msg("ReadDir") + chezmoilog.InfoOrError(s.logger, "ReadDir", err, + chezmoilog.Stringer("name", name), + ) return dirEntries, err } // ReadFile implements System.ReadFile. func (s *DebugSystem) ReadFile(name AbsPath) ([]byte, error) { data, err := s.system.ReadFile(name) - s.logger.Err(err). - Stringer("name", name). - Bytes("data", chezmoilog.Output(data, err)). - Msg("ReadFile") + chezmoilog.InfoOrError(s.logger, "ReadFile", err, + chezmoilog.Stringer("name", name), + slog.Int("size", len(data)), + chezmoilog.FirstFewBytes("data", data), + ) return data, err } // Readlink implements System.Readlink. func (s *DebugSystem) Readlink(name AbsPath) (string, error) { linkname, err := s.system.Readlink(name) - s.logger.Err(err). - Stringer("name", name). - Str("linkname", linkname). - Msg("Readlink") + chezmoilog.InfoOrError(s.logger, "ReadLink", err, + slog.String("linkname", linkname), + ) return linkname, err } +// Remove implements System.Remove. +func (s *DebugSystem) Remove(name AbsPath) error { + err := s.system.Remove(name) + chezmoilog.InfoOrError(s.logger, "Remove", err, + chezmoilog.Stringer("name", name), + ) + return err +} + // RemoveAll implements System.RemoveAll. func (s *DebugSystem) RemoveAll(name AbsPath) error { err := s.system.RemoveAll(name) - s.logger.Err(err). - Stringer("name", name). - Msg("RemoveAll") + chezmoilog.InfoOrError(s.logger, "RemoveAll", err, + chezmoilog.Stringer("name", name), + ) return err } // Rename implements System.Rename. -func (s *DebugSystem) Rename(oldpath, newpath AbsPath) error { - err := s.system.Rename(oldpath, newpath) - s.logger.Err(err). - Stringer("oldpath", oldpath). - Stringer("newpath", newpath). - Msg("Rename") +func (s *DebugSystem) Rename(oldPath, newPath AbsPath) error { + err := s.system.Rename(oldPath, newPath) + chezmoilog.InfoOrError(s.logger, "RemoveAll", err, + chezmoilog.Stringer("oldPath", oldPath), + chezmoilog.Stringer("newPath", newPath), + ) return err } // RunCmd implements System.RunCmd. func (s *DebugSystem) RunCmd(cmd *exec.Cmd) error { + start := time.Now() err := s.system.RunCmd(cmd) - s.logger.Err(err). - EmbedObject(chezmoilog.OSExecCmdLogObject{Cmd: cmd}). - EmbedObject(chezmoilog.OSExecExitErrorLogObject{Err: err}). - Msg("RunCmd") - return err -} - -// RunIdempotentCmd implements System.RunIdempotentCmd. -func (s *DebugSystem) RunIdempotentCmd(cmd *exec.Cmd) error { - err := s.system.RunIdempotentCmd(cmd) - s.logger.Err(err). - EmbedObject(chezmoilog.OSExecCmdLogObject{Cmd: cmd}). - EmbedObject(chezmoilog.OSExecExitErrorLogObject{Err: err}). - Msg("RunIdempotentCmd") + attrs := []slog.Attr{ + slog.Any("cmd", chezmoilog.OSExecCmdLogValuer{Cmd: cmd}), + slog.Duration("duration", time.Since(start)), + } + attrs = chezmoilog.AppendExitErrorAttrs(attrs, err) + chezmoilog.InfoOrError(s.logger, "RunCmd", err, attrs...) return err } // RunScript implements System.RunScript. -func (s *DebugSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte, interpreter *Interpreter) error { - err := s.system.RunScript(scriptname, dir, data, interpreter) - s.logger.Err(err). - Stringer("scriptname", scriptname). - Stringer("dir", dir). - Bytes("data", chezmoilog.Output(data, err)). - Object("interpreter", interpreter). - EmbedObject(chezmoilog.OSExecExitErrorLogObject{Err: err}). - Msg("RunScript") +func (s *DebugSystem) RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) error { + err := s.system.RunScript(scriptName, dir, data, options) + attrs := []slog.Attr{ + chezmoilog.Stringer("scriptName", scriptName), + chezmoilog.Stringer("dir", dir), + chezmoilog.FirstFewBytes("data", data), + slog.Any("interpreter", options.Interpreter), + slog.String("condition", string(options.Condition)), + } + attrs = chezmoilog.AppendExitErrorAttrs(attrs, err) + chezmoilog.InfoOrError(s.logger, "RunScript", err, attrs...) return err } // Stat implements System.Stat. func (s *DebugSystem) Stat(name AbsPath) (fs.FileInfo, error) { fileInfo, err := s.system.Stat(name) - s.logger.Err(err). - Stringer("name", name). - Msg("Stat") + chezmoilog.InfoOrError(s.logger, "Stat", err, + chezmoilog.Stringer("name", name), + ) return fileInfo, err } @@ -198,20 +192,21 @@ func (s *DebugSystem) UnderlyingFS() vfs.FS { // WriteFile implements System.WriteFile. func (s *DebugSystem) WriteFile(name AbsPath, data []byte, perm fs.FileMode) error { err := s.system.WriteFile(name, data, perm) - s.logger.Err(err). - Stringer("name", name). - Bytes("data", chezmoilog.Output(data, err)). - Int("perm", int(perm)). - Msg("WriteFile") + chezmoilog.InfoOrError(s.logger, "WriteFile", err, + chezmoilog.Stringer("name", name), + slog.Int("size", len(data)), + chezmoilog.FirstFewBytes("data", data), + slog.Int("perm", int(perm)), + ) return err } // WriteSymlink implements System.WriteSymlink. -func (s *DebugSystem) WriteSymlink(oldname string, newname AbsPath) error { - err := s.system.WriteSymlink(oldname, newname) - s.logger.Err(err). - Str("oldname", oldname). - Stringer("newname", newname). - Msg("WriteSymlink") +func (s *DebugSystem) WriteSymlink(oldName string, newName AbsPath) error { + err := s.system.WriteSymlink(oldName, newName) + chezmoilog.InfoOrError(s.logger, "WriteSymlink", err, + slog.String("oldName", oldName), + chezmoilog.Stringer("newName", newName), + ) return err } diff --git a/internal/chezmoi/diff.go b/internal/chezmoi/diff.go index 7e82492cc9c..f7c67285feb 100644 --- a/internal/chezmoi/diff.go +++ b/internal/chezmoi/diff.go @@ -64,11 +64,7 @@ func (p *gitDiffPatch) Message() string { return p.message } // DiffPatch returns a github.com/go-git/go-git/plumbing/format/diff.Patch for // path from the given data and mode to the given data and mode. -func DiffPatch( - path RelPath, - fromData []byte, fromMode fs.FileMode, - toData []byte, toMode fs.FileMode, -) (diff.Patch, error) { +func DiffPatch(path RelPath, fromData []byte, fromMode fs.FileMode, toData []byte, toMode fs.FileMode) (diff.Patch, error) { isBinary := isBinary(fromData) || isBinary(toData) var from diff.File @@ -122,13 +118,13 @@ func diffChunks(from, to string) []diff.Chunk { 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 { + chunks := make([]diff.Chunk, len(diffs)) + for i, d := range diffs { chunk := &gitDiffChunk{ content: d.Text, operation: gitDiffOperation[d.Type], } - chunks = append(chunks, chunk) + chunks[i] = chunk } return chunks } diff --git a/internal/chezmoi/dryrunsystem.go b/internal/chezmoi/dryrunsystem.go index f916dd85102..c4151353a71 100644 --- a/internal/chezmoi/dryrunsystem.go +++ b/internal/chezmoi/dryrunsystem.go @@ -3,8 +3,9 @@ package chezmoi import ( "io/fs" "os/exec" + "time" - vfs "github.com/twpayne/go-vfs/v4" + vfs "github.com/twpayne/go-vfs/v5" ) // DryRunSystem is an System that reads from, but does not write to, to @@ -27,23 +28,19 @@ func (s *DryRunSystem) Chmod(name AbsPath, mode fs.FileMode) error { return nil } +// Chtimes implements System.Chtimes. +func (s *DryRunSystem) Chtimes(name AbsPath, atime, mtime time.Time) error { + s.setModified() + return nil +} + // Glob implements System.Glob. func (s *DryRunSystem) Glob(pattern string) ([]string, error) { return s.system.Glob(pattern) } -// IdempotentCmdCombinedOutput implements System.IdempotentCmdCombinedOutput. -func (s *DryRunSystem) IdempotentCmdCombinedOutput(cmd *exec.Cmd) ([]byte, error) { - return s.system.IdempotentCmdCombinedOutput(cmd) -} - -// IdempotentCmdOutput implements System.IdempotentCmdOutput. -func (s *DryRunSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { - return s.system.IdempotentCmdOutput(cmd) -} - // Link implements System.Link. -func (s *DryRunSystem) Link(oldname, newname AbsPath) error { +func (s *DryRunSystem) Link(oldName, newName AbsPath) error { s.setModified() return nil } @@ -85,6 +82,12 @@ func (s *DryRunSystem) Readlink(name AbsPath) (string, error) { return s.system.Readlink(name) } +// Remove implements System.Remove. +func (s *DryRunSystem) Remove(AbsPath) error { + s.setModified() + return nil +} + // RemoveAll implements System.RemoveAll. func (s *DryRunSystem) RemoveAll(AbsPath) error { s.setModified() @@ -92,7 +95,7 @@ func (s *DryRunSystem) RemoveAll(AbsPath) error { } // Rename implements System.Rename. -func (s *DryRunSystem) Rename(oldpath, newpath AbsPath) error { +func (s *DryRunSystem) Rename(oldPath, newPath AbsPath) error { s.setModified() return nil } @@ -103,13 +106,8 @@ func (s *DryRunSystem) RunCmd(cmd *exec.Cmd) error { return nil } -// RunIdempotentCmd implements System.RunIdempotentCmd. -func (s *DryRunSystem) RunIdempotentCmd(cmd *exec.Cmd) error { - return s.system.RunIdempotentCmd(cmd) -} - // RunScript implements System.RunScript. -func (s *DryRunSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte, interpreter *Interpreter) error { +func (s *DryRunSystem) RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) error { s.setModified() return nil } diff --git a/internal/chezmoi/dumpsystem.go b/internal/chezmoi/dumpsystem.go index 76f1be2ee92..e793103e1d4 100644 --- a/internal/chezmoi/dumpsystem.go +++ b/internal/chezmoi/dumpsystem.go @@ -2,8 +2,9 @@ package chezmoi import ( "io/fs" + "os/exec" - vfs "github.com/twpayne/go-vfs/v4" + vfs "github.com/twpayne/go-vfs/v5" ) // A dataType is a data type. @@ -11,6 +12,7 @@ type dataType string // dataTypes. const ( + dataTypeCommand dataType = "command" dataTypeDir dataType = "dir" dataTypeFile dataType = "file" dataTypeScript dataType = "script" @@ -21,80 +23,95 @@ const ( type DumpSystem struct { emptySystemMixin noUpdateSystemMixin - data map[string]interface{} + data map[string]any +} + +// A commandData contains data about a command. +type commandData struct { + Type dataType `json:"type" yaml:"type"` + Path string `json:"path" yaml:"path"` + Args []string `json:"args" yaml:"args"` } // 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 fs.FileMode `json:"perm" toml:"perm" yaml:"perm"` + Type dataType `json:"type" yaml:"type"` + Name AbsPath `json:"name" yaml:"name"` + Perm fs.FileMode `json:"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 fs.FileMode `json:"perm" toml:"perm" yaml:"perm"` + Type dataType `json:"type" yaml:"type"` + Name AbsPath `json:"name" yaml:"name"` + Contents string `json:"contents" yaml:"contents"` + Perm fs.FileMode `json:"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"` - Interpreter *Interpreter `json:"interpreter,omitempty" toml:"interpreter,omitempty" yaml:"interpreter,omitempty"` + Type dataType `json:"type" yaml:"type"` + Name AbsPath `json:"name" yaml:"name"` + Contents string `json:"contents" yaml:"contents"` + Condition string `json:"condition" yaml:"condition"` + Interpreter *Interpreter `json:"interpreter,omitempty" yaml:"interpreter,omitempty"` } // 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"` + Type dataType `json:"type" yaml:"type"` + Name AbsPath `json:"name" yaml:"name"` + Linkname string `json:"linkname" yaml:"linkname"` } // NewDumpSystem returns a new DumpSystem that accumulates data. func NewDumpSystem() *DumpSystem { return &DumpSystem{ - data: make(map[string]interface{}), + data: make(map[string]any), } } // Data returns s's data. -func (s *DumpSystem) Data() interface{} { +func (s *DumpSystem) Data() any { return s.data } // Mkdir implements System.Mkdir. func (s *DumpSystem) Mkdir(dirname AbsPath, perm fs.FileMode) error { - if _, exists := s.data[dirname.String()]; exists { - return fs.ErrExist - } - s.data[dirname.String()] = &dirData{ + return s.setData(dirname.String(), &dirData{ Type: dataTypeDir, Name: dirname, Perm: perm, + }) +} + +// RunCmd implements System.RunCmd. +func (s *DumpSystem) RunCmd(cmd *exec.Cmd) error { + if cmd.Dir == "" { + return nil } - return nil + return s.setData(cmd.Dir, &commandData{ + Type: dataTypeCommand, + Path: cmd.Path, + Args: cmd.Args, + }) } // RunScript implements System.RunScript. -func (s *DumpSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte, interpreter *Interpreter) error { - scriptnameStr := scriptname.String() - if _, exists := s.data[scriptnameStr]; exists { - return fs.ErrExist - } +func (s *DumpSystem) RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) error { + scriptNameStr := scriptName.String() scriptData := &scriptData{ Type: dataTypeScript, - Name: NewAbsPath(scriptnameStr), + Name: NewAbsPath(scriptNameStr), Contents: string(data), } - if !interpreter.None() { - scriptData.Interpreter = interpreter + if options.Condition != ScriptConditionNone { + scriptData.Condition = string(options.Condition) } - s.data[scriptnameStr] = scriptData - return nil + if !options.Interpreter.None() { + scriptData.Interpreter = options.Interpreter + } + return s.setData(scriptNameStr, scriptData) } // UnderlyingFS implements System.UnderlyingFS. @@ -104,29 +121,27 @@ func (s *DumpSystem) UnderlyingFS() vfs.FS { // WriteFile implements System.WriteFile. func (s *DumpSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMode) error { - filenameStr := filename.String() - if _, exists := s.data[filenameStr]; exists { - return fs.ErrExist - } - s.data[filenameStr] = &fileData{ + return s.setData(filename.String(), &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 { - newnameStr := newname.String() - if _, exists := s.data[newnameStr]; exists { - return fs.ErrExist - } - s.data[newnameStr] = &symlinkData{ +func (s *DumpSystem) WriteSymlink(oldName string, newName AbsPath) error { + return s.setData(newName.String(), &symlinkData{ Type: dataTypeSymlink, - Name: newname, - Linkname: oldname, + Name: newName, + Linkname: oldName, + }) +} + +func (s *DumpSystem) setData(key string, value any) error { + if _, ok := s.data[key]; ok { + return fs.ErrExist } + s.data[key] = value return nil } diff --git a/internal/chezmoi/dumpsystem_test.go b/internal/chezmoi/dumpsystem_test.go index 6b54c5c4513..735f6e8bd3a 100644 --- a/internal/chezmoi/dumpsystem_test.go +++ b/internal/chezmoi/dumpsystem_test.go @@ -2,11 +2,12 @@ package chezmoi import ( "context" + "io/fs" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - vfs "github.com/twpayne/go-vfs/v4" + "github.com/alecthomas/assert/v2" + "github.com/coreos/go-semver/semver" + vfs "github.com/twpayne/go-vfs/v5" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -14,16 +15,16 @@ import ( var _ System = &DumpSystem{} func TestDumpSystem(t *testing.T) { - chezmoitest.WithTestFS(t, map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ ".chezmoiignore": "README.md\n", ".chezmoiremove": "*.txt\n", ".chezmoiversion": "1.2.3\n", - ".chezmoitemplates": map[string]interface{}{ + ".chezmoitemplates": map[string]any{ "template": "# contents of .chezmoitemplates/template\n", }, "README.md": "", - "dot_dir": map[string]interface{}{ + "dot_dir": map[string]any{ "file": "# contents of .dir/file\n", }, "run_script": "# contents of script\n", @@ -34,22 +35,29 @@ func TestDumpSystem(t *testing.T) { system := NewRealSystem(fileSystem) s := NewSourceState( WithBaseSystem(system), + WithDestDir(NewAbsPath("/home/user")), WithSourceDir(NewAbsPath("/home/user/.local/share/chezmoi")), WithSystem(system), + WithVersion(semver.Version{ + Major: 1, + Minor: 2, + Patch: 3, + }), ) - require.NoError(t, s.Read(ctx, nil)) + assert.NoError(t, s.Read(ctx, nil)) requireEvaluateAll(t, s, system) dumpSystem := NewDumpSystem() persistentState := NewMockPersistentState() - require.NoError(t, s.applyAll(dumpSystem, system, persistentState, EmptyAbsPath, ApplyOptions{ - Include: NewEntryTypeSet(EntryTypesAll), - })) - expectedData := map[string]interface{}{ + err := s.applyAll(dumpSystem, system, persistentState, EmptyAbsPath, ApplyOptions{ + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), + }) + assert.NoError(t, err) + expectedData := map[string]any{ ".dir": &dirData{ Type: dataTypeDir, Name: NewAbsPath(".dir"), - Perm: 0o777 &^ chezmoitest.Umask, + Perm: fs.ModePerm &^ chezmoitest.Umask, }, ".dir/file": &fileData{ Type: dataTypeFile, @@ -58,9 +66,10 @@ func TestDumpSystem(t *testing.T) { Perm: 0o666 &^ chezmoitest.Umask, }, "script": &scriptData{ - Type: dataTypeScript, - Name: NewAbsPath("script"), - Contents: "# contents of script\n", + Type: dataTypeScript, + Name: NewAbsPath("script"), + Contents: "# contents of script\n", + Condition: "always", }, "symlink": &symlinkData{ Type: dataTypeSymlink, @@ -69,6 +78,6 @@ func TestDumpSystem(t *testing.T) { }, } actualData := dumpSystem.Data() - assert.Equal(t, expectedData, actualData) + assert.Equal(t, expectedData, actualData.(map[string]any)) }) } diff --git a/internal/chezmoi/duration.go b/internal/chezmoi/duration.go new file mode 100644 index 00000000000..016917d9716 --- /dev/null +++ b/internal/chezmoi/duration.go @@ -0,0 +1,15 @@ +package chezmoi + +import "time" + +// A Duration is a time.Duration that implements encoding.TextUnmarshaler. +type Duration time.Duration + +func (d *Duration) UnmarshalText(data []byte) error { + timeDuration, err := time.ParseDuration(string(data)) + if err != nil { + return err + } + *d = Duration(timeDuration) + return nil +} diff --git a/internal/chezmoi/encryption.go b/internal/chezmoi/encryption.go index 99e000ef9db..3b6c60ef5db 100644 --- a/internal/chezmoi/encryption.go +++ b/internal/chezmoi/encryption.go @@ -3,8 +3,8 @@ package chezmoi // An Encryption encrypts and decrypts files and data. type Encryption interface { Decrypt(ciphertext []byte) ([]byte, error) - DecryptToFile(plaintextFilename AbsPath, ciphertext []byte) error + DecryptToFile(plaintextAbsPath AbsPath, ciphertext []byte) error Encrypt(plaintext []byte) ([]byte, error) - EncryptFile(plaintextFilename AbsPath) ([]byte, error) + EncryptFile(plaintextAbsPath AbsPath) ([]byte, error) EncryptedSuffix() string } diff --git a/internal/chezmoi/encryption_test.go b/internal/chezmoi/encryption_test.go index 2576d073e37..486244c38dd 100644 --- a/internal/chezmoi/encryption_test.go +++ b/internal/chezmoi/encryption_test.go @@ -7,8 +7,7 @@ import ( "os/exec" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/alecthomas/assert/v2" ) type xorEncryption struct { @@ -42,20 +41,20 @@ func (e *xorEncryption) EncryptedSuffix() string { } func (e *xorEncryption) xorWithKey(input []byte) []byte { - output := make([]byte, 0, len(input)) - for _, b := range input { - output = append(output, b^e.key) + output := make([]byte, len(input)) + for i, b := range input { + output[i] = b ^ e.key } return output } func lookPathOrSkip(t *testing.T, file string) string { t.Helper() - command, err := exec.LookPath(file) + command, err := LookPath(file) if errors.Is(err, exec.ErrNotFound) { t.Skipf("%s not found in $PATH", file) } - require.NoError(t, err) + assert.NoError(t, err) return command } @@ -65,17 +64,17 @@ func testEncryptionDecryptToFile(t *testing.T, encryption Encryption) { expectedPlaintext := []byte("plaintext\n") actualCiphertext, err := encryption.Encrypt(expectedPlaintext) - require.NoError(t, err) - require.NotEmpty(t, actualCiphertext) + assert.NoError(t, err) + assert.NotZero(t, actualCiphertext) assert.NotEqual(t, expectedPlaintext, actualCiphertext) plaintextAbsPath := NewAbsPath(t.TempDir()).JoinString("plaintext") - require.NoError(t, encryption.DecryptToFile(plaintextAbsPath, actualCiphertext)) + assert.NoError(t, encryption.DecryptToFile(plaintextAbsPath, actualCiphertext)) actualPlaintext, err := os.ReadFile(plaintextAbsPath.String()) - require.NoError(t, err) - require.NotEmpty(t, actualPlaintext) + assert.NoError(t, err) + assert.NotZero(t, actualPlaintext) assert.Equal(t, expectedPlaintext, actualPlaintext) }) } @@ -86,13 +85,13 @@ func testEncryptionEncryptDecrypt(t *testing.T, encryption Encryption) { expectedPlaintext := []byte("plaintext\n") actualCiphertext, err := encryption.Encrypt(expectedPlaintext) - require.NoError(t, err) - require.NotEmpty(t, actualCiphertext) + assert.NoError(t, err) + assert.NotZero(t, actualCiphertext) assert.NotEqual(t, expectedPlaintext, actualCiphertext) actualPlaintext, err := encryption.Decrypt(actualCiphertext) - require.NoError(t, err) - require.NotEmpty(t, actualPlaintext) + assert.NoError(t, err) + assert.NotZero(t, actualPlaintext) assert.Equal(t, expectedPlaintext, actualPlaintext) }) } @@ -103,23 +102,23 @@ func testEncryptionEncryptFile(t *testing.T, encryption Encryption) { expectedPlaintext := []byte("plaintext\n") plaintextAbsPath := NewAbsPath(t.TempDir()).JoinString("plaintext") - require.NoError(t, os.WriteFile(plaintextAbsPath.String(), expectedPlaintext, 0o666)) + assert.NoError(t, os.WriteFile(plaintextAbsPath.String(), expectedPlaintext, 0o666)) actualCiphertext, err := encryption.EncryptFile(plaintextAbsPath) - require.NoError(t, err) - require.NotEmpty(t, actualCiphertext) + assert.NoError(t, err) + assert.NotZero(t, actualCiphertext) assert.NotEqual(t, expectedPlaintext, actualCiphertext) actualPlaintext, err := encryption.Decrypt(actualCiphertext) - require.NoError(t, err) - require.NotEmpty(t, actualPlaintext) + assert.NoError(t, err) + assert.NotZero(t, actualPlaintext) assert.Equal(t, expectedPlaintext, actualPlaintext) }) } func TestXOREncryption(t *testing.T) { testEncryption(t, &xorEncryption{ - key: byte(rand.Int() + 1), + key: byte(rand.Intn(255) + 1), }) } diff --git a/internal/chezmoi/entrystate.go b/internal/chezmoi/entrystate.go index 84d2358fc57..21aa1e759cb 100644 --- a/internal/chezmoi/entrystate.go +++ b/internal/chezmoi/entrystate.go @@ -3,10 +3,9 @@ package chezmoi import ( "bytes" "io/fs" + "log/slog" "runtime" - "github.com/rs/zerolog" - "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) @@ -25,9 +24,9 @@ const ( // 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 fs.FileMode `json:"mode,omitempty" toml:"mode,omitempty" yaml:"mode,omitempty"` - ContentsSHA256 HexBytes `json:"contentsSHA256,omitempty" toml:"contentsSHA256,omitempty" yaml:"contentsSHA256,omitempty"` //nolint:lll,tagliatelle + Type EntryStateType `json:"type" yaml:"type"` + Mode fs.FileMode `json:"mode,omitempty" yaml:"mode,omitempty"` + ContentsSHA256 HexBytes `json:"contentsSHA256,omitempty" yaml:"contentsSHA256,omitempty"` //nolint:tagliatelle contents []byte overwrite bool } @@ -60,24 +59,26 @@ func (s *EntryState) Equivalent(other *EntryState) bool { } } -// Overwrite returns true if s should be overwritten by default. -func (s *EntryState) Overwrite() bool { - return s.overwrite -} - -// MarshalZerologObject implements -// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. -func (s *EntryState) MarshalZerologObject(e *zerolog.Event) { +// LogValue implements log/slog.LogValuer.LogValue. +func (s *EntryState) LogValue() slog.Value { if s == nil { - return + return slog.Value{} + } + attrs := []slog.Attr{ + slog.String("Type", string(s.Type)), + slog.Int("Mode", int(s.Mode)), + chezmoilog.Stringer("ContentsSHA256", s.ContentsSHA256), } - e.Str("Type", string(s.Type)) - e.Int("Mode", int(s.Mode)) - e.Stringer("ContentsSHA256", s.ContentsSHA256) if len(s.contents) != 0 { - e.Bytes("contents", chezmoilog.FirstFewBytes(s.contents)) + attrs = append(attrs, chezmoilog.FirstFewBytes("contents", s.contents)) } if s.overwrite { - e.Bool("overwrite", s.overwrite) + attrs = append(attrs, slog.Bool("overwrite", s.overwrite)) } + return slog.GroupValue(attrs...) +} + +// Overwrite returns true if s should be overwritten by default. +func (s *EntryState) Overwrite() bool { + return s.overwrite } diff --git a/internal/chezmoi/entrystate_test.go b/internal/chezmoi/entrystate_test.go index 73eb8f2d173..3dcadd155b5 100644 --- a/internal/chezmoi/entrystate_test.go +++ b/internal/chezmoi/entrystate_test.go @@ -4,23 +4,23 @@ import ( "fmt" "io/fs" "runtime" - "sort" "testing" + "github.com/alecthomas/assert/v2" "github.com/muesli/combinator" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + + "github.com/twpayne/chezmoi/v2/internal/chezmoimaps" ) func TestEntryStateEquivalent(t *testing.T) { entryStates := map[string]*EntryState{ "dir1": { Type: EntryStateTypeDir, - Mode: fs.ModeDir | 0o777, + Mode: fs.ModeDir | fs.ModePerm, }, "dir1_copy": { Type: EntryStateTypeDir, - Mode: fs.ModeDir | 0o777, + Mode: fs.ModeDir | fs.ModePerm, }, "dir_private": { Type: EntryStateTypeDir, @@ -78,11 +78,7 @@ func TestEntryStateEquivalent(t *testing.T) { "symlink_symlink_copy": true, } - entryStateKeys := make([]string, 0, len(entryStates)) - for entryStateKey := range entryStates { - entryStateKeys = append(entryStateKeys, entryStateKey) - } - sort.Strings(entryStateKeys) + entryStateKeys := chezmoimaps.SortedKeys(entryStates) testData := struct { EntryState1Key []string @@ -95,7 +91,7 @@ func TestEntryStateEquivalent(t *testing.T) { EntryState1Key string EntryState2Key string } - require.NoError(t, combinator.Generate(&testCases, testData)) + assert.NoError(t, combinator.Generate(&testCases, testData)) for _, tc := range testCases { name := fmt.Sprintf("%s_%s", tc.EntryState1Key, tc.EntryState2Key) diff --git a/internal/chezmoi/entrytypefilter.go b/internal/chezmoi/entrytypefilter.go new file mode 100644 index 00000000000..d63f9d0f92c --- /dev/null +++ b/internal/chezmoi/entrytypefilter.go @@ -0,0 +1,40 @@ +package chezmoi + +import "io/fs" + +// An EntryTypeFilter filters entries by type and source attributes. Any entry +// in the include set is included, otherwise if the entry is in the exclude set +// then it is excluded, otherwise it is included. +type EntryTypeFilter struct { + Include *EntryTypeSet + Exclude *EntryTypeSet +} + +// NewEntryTypeFilter returns a new EntryTypeFilter with the given entry type +// bits. +func NewEntryTypeFilter(includeEntryTypeBits, excludeEntryTypeBits EntryTypeBits) *EntryTypeFilter { + return &EntryTypeFilter{ + Include: NewEntryTypeSet(includeEntryTypeBits), + Exclude: NewEntryTypeSet(excludeEntryTypeBits), + } +} + +// IncludeEntryTypeBits returns if entryTypeBits is included. +func (f *EntryTypeFilter) IncludeEntryTypeBits(entryTypeBits EntryTypeBits) bool { + return f.Include.ContainsEntryTypeBits(entryTypeBits) && !f.Exclude.ContainsEntryTypeBits(entryTypeBits) +} + +// IncludeFileInfo returns if fileInfo is included. +func (f *EntryTypeFilter) IncludeFileInfo(fileInfo fs.FileInfo) bool { + return f.Include.ContainsFileInfo(fileInfo) && !f.Exclude.ContainsFileInfo(fileInfo) +} + +// IncludeSourceStateEntry returns if sourceStateEntry is included. +func (f *EntryTypeFilter) IncludeSourceStateEntry(sourceStateEntry SourceStateEntry) bool { + return f.Include.ContainsSourceStateEntry(sourceStateEntry) && !f.Exclude.ContainsSourceStateEntry(sourceStateEntry) +} + +// IncludeTargetStateEntry returns if targetStateEntry is included. +func (f *EntryTypeFilter) IncludeTargetStateEntry(targetStateEntry TargetStateEntry) bool { + return f.Include.ContainsTargetStateEntry(targetStateEntry) && !f.Exclude.ContainsTargetStateEntry(targetStateEntry) +} diff --git a/internal/chezmoi/entrytypeset.go b/internal/chezmoi/entrytypeset.go index b25679fe3b0..4ec13fff050 100644 --- a/internal/chezmoi/entrytypeset.go +++ b/internal/chezmoi/entrytypeset.go @@ -7,6 +7,9 @@ import ( "strings" "github.com/mitchellh/mapstructure" + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/v2/internal/chezmoimaps" ) // An EntryTypeSet is a set of entry types. It parses and prints as a @@ -27,6 +30,9 @@ const ( EntryTypeScripts EntryTypeSymlinks EntryTypeEncrypted + EntryTypeExternals + EntryTypeTemplates + EntryTypeAlways // EntryTypesAll is all entry types. EntryTypesAll EntryTypeBits = EntryTypeDirs | @@ -34,22 +40,55 @@ const ( EntryTypeRemove | EntryTypeScripts | EntryTypeSymlinks | - EntryTypeEncrypted + EntryTypeEncrypted | + EntryTypeExternals | + EntryTypeTemplates | + EntryTypeAlways // EntryTypesNone is no entry types. EntryTypesNone EntryTypeBits = 0 ) -// entryTypeBits is a map from human-readable strings to EntryTypeBits. -var entryTypeBits = map[string]EntryTypeBits{ - "all": EntryTypesAll, - "dirs": EntryTypeDirs, - "files": EntryTypeFiles, - "remove": EntryTypeRemove, - "scripts": EntryTypeScripts, - "symlinks": EntryTypeSymlinks, - "encrypted": EntryTypeEncrypted, -} +var ( + // entryTypeBits is a map from human-readable strings to EntryTypeBits. + entryTypeBits = map[string]EntryTypeBits{ + "all": EntryTypesAll, + "always": EntryTypeAlways, + "dirs": EntryTypeDirs, + "files": EntryTypeFiles, + "remove": EntryTypeRemove, + "scripts": EntryTypeScripts, + "symlinks": EntryTypeSymlinks, + "encrypted": EntryTypeEncrypted, + "externals": EntryTypeExternals, + "templates": EntryTypeTemplates, + } + + entryTypeStrings = chezmoimaps.SortedKeys(entryTypeBits) + + entryTypeCompletions = []string{ + "all", + "always", + "dirs", + "encrypted", + "externals", + "files", + "noalways", + "nodirs", + "noencrypted", + "noexternals", + "nofiles", + "none", + "noremove", + "noscripts", + "nosymlinks", + "notemplates", + "remove", + "scripts", + "symlinks", + "templates", + } +) // NewEntryTypeSet returns a new IncludeSet. func NewEntryTypeSet(bits EntryTypeBits) *EntryTypeSet { @@ -58,18 +97,18 @@ func NewEntryTypeSet(bits EntryTypeBits) *EntryTypeSet { } } -// Include returns if s includes b. -func (s *EntryTypeSet) Include(b EntryTypeBits) bool { - return s.bits&b != 0 +// Bits returns s's bits. +func (s *EntryTypeSet) Bits() EntryTypeBits { + return s.bits } -// IncludeEncrypted returns true if s includes encrypted files. -func (s *EntryTypeSet) IncludeEncrypted() bool { - return s.bits&EntryTypeEncrypted != 0 +// ContainsEntryTypeBits returns if s includes b. +func (s *EntryTypeSet) ContainsEntryTypeBits(b EntryTypeBits) bool { + return s.bits&b != 0 } -// IncludeFileInfo returns true if the type of fileInfo is a member. -func (s *EntryTypeSet) IncludeFileInfo(fileInfo fs.FileInfo) bool { +// ContainsFileInfo returns true if fileInfo is a member. +func (s *EntryTypeSet) ContainsFileInfo(fileInfo fs.FileInfo) bool { switch { case fileInfo.IsDir(): return s.bits&EntryTypeDirs != 0 @@ -82,24 +121,165 @@ func (s *EntryTypeSet) IncludeFileInfo(fileInfo fs.FileInfo) bool { } } -// IncludeTargetStateEntry returns true if type of targetStateEntry is a member. -func (s *EntryTypeSet) IncludeTargetStateEntry(targetStateEntry TargetStateEntry) bool { +// ContainsSourceStateEntry returns true if sourceStateEntry is a member. +func (s *EntryTypeSet) ContainsSourceStateEntry(sourceStateEntry SourceStateEntry) bool { + _, isExternal := sourceStateEntry.Origin().(*External) + switch sourceStateEntry := sourceStateEntry.(type) { + case *SourceStateCommand: + switch { + case s.bits&EntryTypeExternals != 0 && isExternal: + return true + case s.bits&EntryTypeDirs != 0: + return true + default: + return false + } + case *SourceStateDir, *SourceStateImplicitDir: + switch { + case s.bits&EntryTypeExternals != 0 && isExternal: + return true + case s.bits&EntryTypeDirs != 0: + return true + default: + return false + } + case *SourceStateFile: + switch sourceAttr := sourceStateEntry.Attr; { + case s.bits&EntryTypeExternals != 0 && isExternal: + return true + case s.bits&EntryTypeEncrypted != 0 && sourceAttr.Encrypted: + return true + case s.bits&EntryTypeTemplates != 0 && sourceAttr.Template: + return true + case s.bits&EntryTypeFiles != 0 && sourceAttr.Type == SourceFileTypeCreate: + return true + case s.bits&EntryTypeFiles != 0 && sourceAttr.Type == SourceFileTypeFile: + return true + case s.bits&EntryTypeFiles != 0 && sourceAttr.Type == SourceFileTypeModify: + return true + case s.bits&EntryTypeRemove != 0 && sourceAttr.Type == SourceFileTypeRemove: + return true + case s.bits&EntryTypeScripts != 0 && sourceAttr.Type == SourceFileTypeScript: + return true + case s.bits&EntryTypeSymlinks != 0 && sourceAttr.Type == SourceFileTypeSymlink: + return true + case s.bits&EntryTypeAlways != 0 && sourceAttr.Condition == ScriptConditionAlways: + return true + default: + return false + } + case *SourceStateRemove: + switch { + case s.bits&EntryTypeExternals != 0 && isExternal: + return true + case s.bits&EntryTypeRemove != 0: + return true + default: + return false + } + default: + panic(fmt.Sprintf("%T: unsupported type", sourceStateEntry)) + } +} + +// ContainsTargetStateEntry returns true if targetStateEntry is a member. +func (s *EntryTypeSet) ContainsTargetStateEntry(targetStateEntry TargetStateEntry) bool { + sourceAttr := targetStateEntry.SourceAttr() switch targetStateEntry.(type) { case *TargetStateDir: - return s.bits&EntryTypeDirs != 0 + switch { + case s.bits&EntryTypeExternals != 0 && sourceAttr.External: + return true + case s.bits&EntryTypeDirs != 0: + return true + default: + return false + } case *TargetStateFile: - return s.bits&EntryTypeFiles != 0 + switch { + case s.bits&EntryTypeEncrypted != 0 && sourceAttr.Encrypted: + return true + case s.bits&EntryTypeExternals != 0 && sourceAttr.External: + return true + case s.bits&EntryTypeTemplates != 0 && sourceAttr.Template: + return true + case s.bits&EntryTypeFiles != 0: + return true + default: + return false + } + case *TargetStateModifyDirWithCmd: + switch { + case s.bits&EntryTypeExternals != 0 && sourceAttr.External: + return true + case s.bits&EntryTypeDirs != 0: + return true + default: + return false + } case *TargetStateRemove: return s.bits&EntryTypeRemove != 0 case *TargetStateScript: - return s.bits&EntryTypeScripts != 0 + switch { + case s.bits&EntryTypeEncrypted != 0 && sourceAttr.Encrypted: + return true + case s.bits&EntryTypeTemplates != 0 && sourceAttr.Template: + return true + case s.bits&EntryTypeAlways != 0 && sourceAttr.Condition == ScriptConditionAlways: + return true + case s.bits&EntryTypeScripts != 0: + return true + default: + return false + } case *TargetStateSymlink: - return s.bits&EntryTypeSymlinks != 0 - case *targetStateRenameDir: - return s.bits&EntryTypeDirs != 0 + switch { + case s.bits&EntryTypeEncrypted != 0 && sourceAttr.Encrypted: + return true + case s.bits&EntryTypeExternals != 0 && sourceAttr.External: + return true + case s.bits&EntryTypeTemplates != 0 && sourceAttr.Template: + return true + case s.bits&EntryTypeSymlinks != 0: + return true + default: + return false + } default: - return false + panic(fmt.Sprintf("%T: unsupported type", targetStateEntry)) + } +} + +// MarshalJSON implements encoding/json.Marshaler.MarshalJSON. +func (s *EntryTypeSet) MarshalJSON() ([]byte, error) { + switch s.bits { + case EntryTypesAll: + return []byte(`["all"]`), nil + case EntryTypesNone: + return []byte("[]"), nil + default: + var elements []string + for _, key := range entryTypeStrings { + if bit := entryTypeBits[key]; s.bits&bit == bit { + elements = append(elements, `"`+key+`"`) + } + } + return []byte("[" + strings.Join(elements, ",") + "]"), nil + } +} + +// MarshalYAML implements gopkg.in/yaml.v3.Marshaler. +func (s *EntryTypeSet) MarshalYAML() (any, error) { + if s.bits == EntryTypesAll { + return []string{"all"}, nil + } + var result []string + for _, key := range entryTypeStrings { + if bit := entryTypeBits[key]; s.bits&bit == bit { + result = append(result, key) + } } + return result, nil } // Set implements github.com/spf13/pflag.Value.Set. @@ -118,11 +298,7 @@ func (s *EntryTypeSet) SetSlice(ss []string) error { if element == "" { continue } - exclude := false - if strings.HasPrefix(element, "no") { - exclude = true - element = element[2:] - } + element, exclude := strings.CutPrefix(element, "no") bit, ok := entryTypeBits[element] if !ok { return fmt.Errorf("%s: unknown entry type", element) @@ -151,29 +327,14 @@ func (s *EntryTypeSet) String() string { case EntryTypesNone: return "none" } - var elements []string - for i, element := range []string{ - "dirs", - "files", - "remove", - "scripts", - "symlinks", - } { - if s.bits&(1< 0 { + prefix = toComplete[:len(toComplete)-len(lastEntryType)] + } + for _, completion := range entryTypeCompletions { + if strings.HasPrefix(completion, lastEntryType) { + completions = append(completions, prefix+completion) + } + } + return completions, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp +} diff --git a/internal/chezmoi/entrytypeset_test.go b/internal/chezmoi/entrytypeset_test.go index 86c5f5a5ebf..f162bb710c2 100644 --- a/internal/chezmoi/entrytypeset_test.go +++ b/internal/chezmoi/entrytypeset_test.go @@ -3,8 +3,8 @@ package chezmoi import ( "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/alecthomas/assert/v2" + "github.com/spf13/cobra" ) func TestIncludeMaskSet(t *testing.T) { @@ -31,7 +31,7 @@ func TestIncludeMaskSet(t *testing.T) { }, { s: "all,noscripts", - expected: NewEntryTypeSet(EntryTypeDirs | EntryTypeFiles | EntryTypeRemove | EntryTypeSymlinks | EntryTypeEncrypted), + expected: NewEntryTypeSet(EntryTypesAll &^ EntryTypeScripts), }, { s: "noscripts", @@ -54,9 +54,9 @@ func TestIncludeMaskSet(t *testing.T) { actual := NewEntryTypeSet(EntryTypesNone) err := actual.Set(tc.s) if tc.expectedErr { - require.Error(t, err) + assert.Error(t, err) } else { - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, tc.expected, actual) } }) @@ -92,6 +92,14 @@ func TestIncludeMaskStringSlice(t *testing.T) { bits: EntryTypeSymlinks, expected: "symlinks", }, + { + bits: EntryTypeEncrypted, + expected: "encrypted", + }, + { + bits: EntryTypeExternals, + expected: "externals", + }, { bits: EntryTypesNone, expected: "none", @@ -106,3 +114,45 @@ func TestIncludeMaskStringSlice(t *testing.T) { }) } } + +func TestEntryTypeSetFlagCompletionFunc(t *testing.T) { + for _, tc := range []struct { + toComplete string + expectedCompletions []string + }{ + { + toComplete: "a", + expectedCompletions: []string{ + "all", + "always", + }, + }, + { + toComplete: "e", + expectedCompletions: []string{ + "encrypted", + "externals", + }, + }, + { + toComplete: "t", + expectedCompletions: []string{ + "templates", + }, + }, + { + toComplete: "all,nos", + expectedCompletions: []string{ + "all,noscripts", + "all,nosymlinks", + }, + }, + } { + t.Run(tc.toComplete, func(t *testing.T) { + completions, shellCompDirective := EntryTypeSetFlagCompletionFunc(nil, nil, tc.toComplete) + assert.Equal(t, tc.expectedCompletions, completions) + expectedShellCompDirective := cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + assert.Equal(t, expectedShellCompDirective, shellCompDirective) + }) + } +} diff --git a/internal/chezmoi/erroronwritesystem.go b/internal/chezmoi/erroronwritesystem.go new file mode 100644 index 00000000000..bd4562bdb89 --- /dev/null +++ b/internal/chezmoi/erroronwritesystem.go @@ -0,0 +1,120 @@ +package chezmoi + +import ( + "io/fs" + "os/exec" + "time" + + vfs "github.com/twpayne/go-vfs/v5" +) + +// An ErrorOnWriteSystem is an System that passes reads to the wrapped System +// and returns an error if it is written to. +type ErrorOnWriteSystem struct { + system System + err error +} + +// NewErrorOnWriteSystem returns a new ErrorOnWriteSystem that wraps fs and +// returns err on any write operation. +func NewErrorOnWriteSystem(system System, err error) *ErrorOnWriteSystem { + return &ErrorOnWriteSystem{ + system: system, + err: err, + } +} + +// Chmod implements System.Chmod. +func (s *ErrorOnWriteSystem) Chmod(name AbsPath, mode fs.FileMode) error { + return s.err +} + +// Chtimes implements System.Chtimes. +func (s *ErrorOnWriteSystem) Chtimes(name AbsPath, atime, mtime time.Time) error { + return s.err +} + +// Glob implements System.Glob. +func (s *ErrorOnWriteSystem) Glob(pattern string) ([]string, error) { + return s.system.Glob(pattern) +} + +// Link implements System.Link. +func (s *ErrorOnWriteSystem) Link(oldName, newName AbsPath) error { + return s.err +} + +// Lstat implements System.Lstat. +func (s *ErrorOnWriteSystem) Lstat(name AbsPath) (fs.FileInfo, error) { + return s.system.Lstat(name) +} + +// Mkdir implements System.Mkdir. +func (s *ErrorOnWriteSystem) Mkdir(name AbsPath, perm fs.FileMode) error { + return s.err +} + +// RawPath implements System.RawPath. +func (s *ErrorOnWriteSystem) RawPath(path AbsPath) (AbsPath, error) { + return s.system.RawPath(path) +} + +// ReadDir implements System.ReadDir. +func (s *ErrorOnWriteSystem) ReadDir(name AbsPath) ([]fs.DirEntry, error) { + return s.system.ReadDir(name) +} + +// ReadFile implements System.ReadFile. +func (s *ErrorOnWriteSystem) ReadFile(name AbsPath) ([]byte, error) { + return s.system.ReadFile(name) +} + +// Readlink implements System.Readlink. +func (s *ErrorOnWriteSystem) Readlink(name AbsPath) (string, error) { + return s.system.Readlink(name) +} + +// Remove implements System.Remove. +func (s *ErrorOnWriteSystem) Remove(AbsPath) error { + return s.err +} + +// RemoveAll implements System.RemoveAll. +func (s *ErrorOnWriteSystem) RemoveAll(AbsPath) error { + return s.err +} + +// Rename implements System.Rename. +func (s *ErrorOnWriteSystem) Rename(oldPath, newPath AbsPath) error { + return s.err +} + +// RunCmd implements System.RunCmd. +func (s *ErrorOnWriteSystem) RunCmd(cmd *exec.Cmd) error { + return s.err +} + +// RunScript implements System.RunScript. +func (s *ErrorOnWriteSystem) RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) error { + return s.err +} + +// Stat implements System.Stat. +func (s *ErrorOnWriteSystem) Stat(name AbsPath) (fs.FileInfo, error) { + return s.system.Stat(name) +} + +// UnderlyingFS implements System.UnderlyingFS. +func (s *ErrorOnWriteSystem) UnderlyingFS() vfs.FS { + return s.system.UnderlyingFS() +} + +// WriteFile implements System.WriteFile. +func (s *ErrorOnWriteSystem) WriteFile(AbsPath, []byte, fs.FileMode) error { + return s.err +} + +// WriteSymlink implements System.WriteSymlink. +func (s *ErrorOnWriteSystem) WriteSymlink(string, AbsPath) error { + return s.err +} diff --git a/internal/chezmoi/erroronwritesystem_test.go b/internal/chezmoi/erroronwritesystem_test.go new file mode 100644 index 00000000000..39f85874338 --- /dev/null +++ b/internal/chezmoi/erroronwritesystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &ErrorOnWriteSystem{} diff --git a/internal/chezmoi/errors.go b/internal/chezmoi/errors.go new file mode 100644 index 00000000000..22555b98a79 --- /dev/null +++ b/internal/chezmoi/errors.go @@ -0,0 +1,66 @@ +package chezmoi + +import ( + "fmt" + "io/fs" + "strings" + + "github.com/coreos/go-semver/semver" +) + +// An ExitCodeError indicates the main program should exit with the given +// code. +type ExitCodeError int + +func (e ExitCodeError) Error() string { + return fmt.Sprintf("exit status %d", int(e)) +} + +// A TooOldError is returned when the source state requires a newer version of +// chezmoi. +type TooOldError struct { + Have semver.Version + Need semver.Version +} + +func (e *TooOldError) Error() string { + format := "source state requires chezmoi version %s or later, chezmoi is version %s" + return fmt.Sprintf(format, e.Need, e.Have) +} + +type inconsistentStateError struct { + targetRelPath RelPath + origins []string +} + +func (e *inconsistentStateError) Error() string { + format := "%s: inconsistent state (%s)" + return fmt.Sprintf(format, e.targetRelPath, strings.Join(e.origins, ", ")) +} + +type NotInAbsDirError struct { + pathAbsPath AbsPath + dirAbsPath AbsPath +} + +func (e *NotInAbsDirError) Error() string { + return fmt.Sprintf("%s: not in %s", e.pathAbsPath, e.dirAbsPath) +} + +type notInRelDirError struct { + pathRelPath RelPath + dirRelPath RelPath +} + +func (e *notInRelDirError) Error() string { + return fmt.Sprintf("%s: not in %s", e.pathRelPath, e.dirRelPath) +} + +type unsupportedFileTypeError struct { + absPath AbsPath + mode fs.FileMode +} + +func (e *unsupportedFileTypeError) Error() string { + return fmt.Sprintf("%s: unsupported file type %s", e.absPath, modeTypeName(e.mode)) +} diff --git a/internal/chezmoi/externaldiffsystem.go b/internal/chezmoi/externaldiffsystem.go index f3d62c2d81d..5a3b96415e2 100644 --- a/internal/chezmoi/externaldiffsystem.go +++ b/internal/chezmoi/externaldiffsystem.go @@ -1,15 +1,20 @@ package chezmoi import ( + "bytes" "errors" "io/fs" + "log/slog" "os" "os/exec" "strconv" "strings" "text/template" + "time" - vfs "github.com/twpayne/go-vfs/v4" + vfs "github.com/twpayne/go-vfs/v5" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) // An ExternalDiffSystem is a DiffSystem that uses an external diff tool. @@ -19,24 +24,34 @@ type ExternalDiffSystem struct { args []string destDirAbsPath AbsPath tempDirAbsPath AbsPath + filter *EntryTypeFilter reverse bool + scriptContents bool } // ExternalDiffSystemOptions are options for NewExternalDiffSystem. type ExternalDiffSystemOptions struct { - Reverse bool + Filter *EntryTypeFilter + Reverse bool + ScriptContents bool } // NewExternalDiffSystem creates a new ExternalDiffSystem. func NewExternalDiffSystem( - system System, command string, args []string, destDirAbsPath AbsPath, options *ExternalDiffSystemOptions, + system System, + command string, + args []string, + destDirAbsPath AbsPath, + options *ExternalDiffSystemOptions, ) *ExternalDiffSystem { return &ExternalDiffSystem{ system: system, command: command, args: args, destDirAbsPath: destDirAbsPath, + filter: options.Filter, reverse: options.Reverse, + scriptContents: options.ScriptContents, } } @@ -53,28 +68,24 @@ func (s *ExternalDiffSystem) Close() error { // Chmod implements System.Chmod. func (s *ExternalDiffSystem) Chmod(name AbsPath, mode fs.FileMode) error { + // FIXME generate suitable inputs for s.command return s.system.Chmod(name, mode) } +// Chtimes implements System.Chtimes. +func (s *ExternalDiffSystem) Chtimes(name AbsPath, atime, mtime time.Time) error { + return s.system.Chtimes(name, atime, mtime) +} + // Glob implements System.Glob. func (s *ExternalDiffSystem) Glob(pattern string) ([]string, error) { return s.system.Glob(pattern) } -// IdempotentCmdCombinedOutput implements System.IdempotentCmdCombinedOutput. -func (s *ExternalDiffSystem) IdempotentCmdCombinedOutput(cmd *exec.Cmd) ([]byte, error) { - return s.system.IdempotentCmdCombinedOutput(cmd) -} - -// IdempotentCmdOutput implements System.IdempotentCmdOutput. -func (s *ExternalDiffSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { - return s.system.IdempotentCmdOutput(cmd) -} - // Link implements System.Link. -func (s *ExternalDiffSystem) Link(oldname, newname AbsPath) error { +func (s *ExternalDiffSystem) Link(oldName, newName AbsPath) error { // FIXME generate suitable inputs for s.command - return s.system.Link(oldname, newname) + return s.system.Link(oldName, newName) } // Lstat implements System.Lstat. @@ -84,6 +95,23 @@ func (s *ExternalDiffSystem) Lstat(name AbsPath) (fs.FileInfo, error) { // Mkdir implements System.Mkdir. func (s *ExternalDiffSystem) Mkdir(name AbsPath, perm fs.FileMode) error { + if s.filter.IncludeEntryTypeBits(EntryTypeDirs) { + targetRelPath, err := name.TrimDirPrefix(s.destDirAbsPath) + if err != nil { + return err + } + tempDirAbsPath, err := s.tempDir() + if err != nil { + return err + } + targetAbsPath := tempDirAbsPath.Join(targetRelPath) + if err := os.MkdirAll(targetAbsPath.String(), perm); err != nil { + return err + } + if err := s.runDiffCommand(devNullAbsPath, targetAbsPath); err != nil { + return err + } + } return s.system.Mkdir(name, perm) } @@ -107,16 +135,44 @@ func (s *ExternalDiffSystem) Readlink(name AbsPath) (string, error) { return s.system.Readlink(name) } +// Remove implements System.Remove. +func (s *ExternalDiffSystem) Remove(name AbsPath) error { + if s.filter.IncludeEntryTypeBits(EntryTypeRemove) { + switch fileInfo, err := s.system.Lstat(name); { + case errors.Is(err, fs.ErrNotExist): + // Do nothing. + case err != nil: + return err + case s.filter.IncludeFileInfo(fileInfo): + if err := s.runDiffCommand(name, devNullAbsPath); err != nil { + return err + } + } + } + return s.system.Remove(name) +} + // RemoveAll implements System.RemoveAll. func (s *ExternalDiffSystem) RemoveAll(name AbsPath) error { - // FIXME generate suitable inputs for s.command + if s.filter.IncludeEntryTypeBits(EntryTypeRemove) { + switch fileInfo, err := s.system.Lstat(name); { + case errors.Is(err, fs.ErrNotExist): + // Do nothing. + case err != nil: + return err + case s.filter.IncludeFileInfo(fileInfo): + if err := s.runDiffCommand(name, devNullAbsPath); err != nil { + return err + } + } + } return s.system.RemoveAll(name) } // Rename implements System.Rename. -func (s *ExternalDiffSystem) Rename(oldpath, newpath AbsPath) error { +func (s *ExternalDiffSystem) Rename(oldPath, newPath AbsPath) error { // FIXME generate suitable inputs for s.command - return s.system.Rename(oldpath, newpath) + return s.system.Rename(oldPath, newPath) } // RunCmd implements System.RunCmd. @@ -124,15 +180,33 @@ func (s *ExternalDiffSystem) RunCmd(cmd *exec.Cmd) error { return s.system.RunCmd(cmd) } -// RunIdempotentCmd implements System.RunIdempotentCmd. -func (s *ExternalDiffSystem) RunIdempotentCmd(cmd *exec.Cmd) error { - return s.system.RunIdempotentCmd(cmd) -} - // RunScript implements System.RunScript. -func (s *ExternalDiffSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte, interpreter *Interpreter) error { - // FIXME generate suitable inputs for s.command - return s.system.RunScript(scriptname, dir, data, interpreter) +func (s *ExternalDiffSystem) RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) error { + bits := EntryTypeScripts + if options.Condition == ScriptConditionAlways { + bits |= EntryTypeAlways + } + if s.filter.IncludeEntryTypeBits(bits) { + tempDirAbsPath, err := s.tempDir() + if err != nil { + return err + } + targetAbsPath := tempDirAbsPath.Join(scriptName) + if err := os.MkdirAll(targetAbsPath.Dir().String(), 0o700); err != nil { + return err + } + toData := data + if !s.scriptContents { + toData = nil + } + if err := os.WriteFile(targetAbsPath.String(), toData, 0o700); err != nil { //nolint:gosec + return err + } + if err := s.runDiffCommand(devNullAbsPath, targetAbsPath); err != nil { + return err + } + } + return s.system.RunScript(scriptName, dir, data, options) } // Stat implements System.Stat. @@ -147,28 +221,45 @@ func (s *ExternalDiffSystem) UnderlyingFS() vfs.FS { // WriteFile implements System.WriteFile. func (s *ExternalDiffSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMode) error { - targetRelPath, err := filename.TrimDirPrefix(s.destDirAbsPath) - if err != nil { - return err - } - tempDirAbsPath, err := s.tempDir() - if err != nil { - return err - } - targetAbsPath := tempDirAbsPath.Join(targetRelPath) - if err := os.MkdirAll(targetAbsPath.Dir().String(), 0o700); err != nil { - return err - } - if err := os.WriteFile(targetAbsPath.String(), data, perm); err != nil { - return err + if s.filter.IncludeEntryTypeBits(EntryTypeFiles) { + // If filename does not exist, replace it with /dev/null to avoid + // passing the name of a non-existent file to the external diff command. + destAbsPath := filename + switch _, err := os.Stat(destAbsPath.String()); { + case errors.Is(err, fs.ErrNotExist): + destAbsPath = devNullAbsPath + case err != nil: + return err + } + + // Write the target contents to a file in a temporary directory. + targetRelPath, err := filename.TrimDirPrefix(s.destDirAbsPath) + if err != nil { + return err + } + tempDirAbsPath, err := s.tempDir() + if err != nil { + return err + } + targetAbsPath := tempDirAbsPath.Join(targetRelPath) + if err := os.MkdirAll(targetAbsPath.Dir().String(), 0o700); err != nil { + return err + } + if err := os.WriteFile(targetAbsPath.String(), data, perm); err != nil { + return err + } + + if err := s.runDiffCommand(destAbsPath, targetAbsPath); err != nil { + return err + } } - return s.runDiffCommand(filename, targetAbsPath) + return s.system.WriteFile(filename, data, perm) } // WriteSymlink implements System.WriteSymlink. -func (s *ExternalDiffSystem) WriteSymlink(oldname string, newname AbsPath) error { +func (s *ExternalDiffSystem) WriteSymlink(oldName string, newName AbsPath) error { // FIXME generate suitable inputs for s.command - return s.system.WriteSymlink(oldname, newname) + return s.system.WriteSymlink(oldName, newName) } // tempDir creates a temporary directory for s if it does not already exist and @@ -207,7 +298,7 @@ func (s *ExternalDiffSystem) runDiffCommand(destAbsPath, targetAbsPath AbsPath) // option replaced all arguments to the diff command. // // Work around this by looking for any templates in diff.args. An arg is - // considered a template if, after execution as as template, it is not equal + // considered a template if, after execution as a template, it is not equal // to the original arg. anyTemplateArgs := false for i, arg := range s.args { @@ -234,10 +325,32 @@ func (s *ExternalDiffSystem) runDiffCommand(destAbsPath, targetAbsPath AbsPath) args = append(args, templateData.Destination, templateData.Target) } - //nolint:gosec - cmd := exec.Command(s.command, args...) + cmd := exec.Command(s.command, args...) //nolint:gosec cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - return s.system.RunIdempotentCmd(cmd) + err := chezmoilog.LogCmdRun(slog.Default(), cmd) + + // Swallow exit status 1 errors if the files differ as diff commands + // traditionally exit with code 1 in this case. + if exitError := (&exec.ExitError{}); errors.As(err, &exitError) && exitError.ProcessState.ExitCode() == 1 { + destData, err2 := s.ReadFile(destAbsPath) + switch { + case errors.Is(err2, fs.ErrNotExist): + // Do nothing. + case err2 != nil: + return errors.Join(err, err2) + } + targetData, err2 := s.ReadFile(targetAbsPath) + switch { + case errors.Is(err2, fs.ErrNotExist): + // Do nothing. + case err2 != nil: + return errors.Join(err, err2) + } + if !bytes.Equal(destData, targetData) { + return nil + } + } + return err } diff --git a/internal/chezmoi/externaldiffsystem_test.go b/internal/chezmoi/externaldiffsystem_test.go new file mode 100644 index 00000000000..3329e0139e5 --- /dev/null +++ b/internal/chezmoi/externaldiffsystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &ExternalDiffSystem{} diff --git a/internal/chezmoi/findexecutable.go b/internal/chezmoi/findexecutable.go new file mode 100644 index 00000000000..d79e1eeca53 --- /dev/null +++ b/internal/chezmoi/findexecutable.go @@ -0,0 +1,66 @@ +package chezmoi + +import ( + "os" + "path/filepath" + "strings" + "sync" +) + +var ( + foundExecutableCacheMutex sync.Mutex + foundExecutableCache = make(map[string]string) +) + +// FindExecutable is like LookPath except that: +// +// - You can specify the needle as `string`, `[]string`, or `[]interface{}` +// (that converts to `[]string`). +// - You specify the haystack instead of relying on `$PATH`/`%PATH%`. +// +// This makes it useful for the resulting path of shell configurations +// managed by chezmoi. +func FindExecutable(files, paths []string) (string, error) { + foundExecutableCacheMutex.Lock() + defer foundExecutableCacheMutex.Unlock() + + key := strings.Join(files, "\x00") + "\x01" + strings.Join(paths, "\x00") + + if path, ok := foundExecutableCache[key]; ok { + return path, nil + } + + var candidates []string + + for _, file := range files { + candidates = append(candidates, findExecutableExtensions(file)...) + } + + // based on /usr/lib/go-1.20/src/os/exec/lp_unix.go:52 + for _, candidatePath := range paths { + if candidatePath == "" { + continue + } + + for _, candidate := range candidates { + path := filepath.Join(candidatePath, candidate) + + info, err := os.Stat(path) + if err != nil { + continue + } + + // isExecutable doesn't care if it's a directory + if info.Mode().IsDir() { + continue + } + + if IsExecutable(info) { + foundExecutableCache[key] = path + return path, nil + } + } + } + + return "", nil +} diff --git a/internal/chezmoi/findexecutable_darwin_test.go b/internal/chezmoi/findexecutable_darwin_test.go new file mode 100644 index 00000000000..42c74fd1229 --- /dev/null +++ b/internal/chezmoi/findexecutable_darwin_test.go @@ -0,0 +1,58 @@ +package chezmoi + +import ( + "fmt" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestFindExecutable(t *testing.T) { + tests := []struct { + files []string + paths []string + expected string + }{ + { + files: []string{"sh"}, + paths: []string{"/usr/bin", "/bin"}, + expected: "/bin/sh", + }, + { + files: []string{"sh"}, + paths: []string{"/bin", "/usr/bin"}, + expected: "/bin/sh", + }, + { + files: []string{"chezmoish"}, + paths: []string{"/bin", "/usr/bin"}, + expected: "", + }, + + { + files: []string{"chezmoish", "sh"}, + paths: []string{"/usr/bin", "/bin"}, + expected: "/bin/sh", + }, + { + files: []string{"chezmoish", "sh"}, + paths: []string{"/bin", "/usr/bin"}, + expected: "/bin/sh", + }, + { + files: []string{"chezmoish", "chezvoush"}, + paths: []string{"/bin", "/usr/bin"}, + expected: "", + }, + } + + for _, test := range tests { + format := "FindExecutable %#v in %#v as %#v" + name := fmt.Sprintf(format, test.files, test.paths, test.expected) + t.Run(name, func(t *testing.T) { + actual, err := FindExecutable(test.files, test.paths) + assert.NoError(t, err) + assert.Equal(t, test.expected, actual) + }) + } +} diff --git a/internal/chezmoi/findexecutable_unix_test.go b/internal/chezmoi/findexecutable_unix_test.go new file mode 100644 index 00000000000..f25d133fb8d --- /dev/null +++ b/internal/chezmoi/findexecutable_unix_test.go @@ -0,0 +1,59 @@ +//go:build !windows && !darwin + +package chezmoi + +import ( + "fmt" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestFindExecutable(t *testing.T) { + tests := []struct { + files []string + paths []string + expected string + }{ + { + files: []string{"yes"}, + paths: []string{"/usr/bin", "/bin"}, + expected: "/usr/bin/yes", + }, + { + files: []string{"sh"}, + paths: []string{"/bin", "/usr/bin"}, + expected: "/bin/sh", + }, + { + files: []string{"chezmoish"}, + paths: []string{"/bin", "/usr/bin"}, + expected: "", + }, + { + files: []string{"chezmoish", "yes"}, + paths: []string{"/usr/bin", "/bin"}, + expected: "/usr/bin/yes", + }, + { + files: []string{"chezmoish", "sh"}, + paths: []string{"/bin", "/usr/bin"}, + expected: "/bin/sh", + }, + { + files: []string{"chezmoish", "chezvoush"}, + paths: []string{"/bin", "/usr/bin"}, + expected: "", + }, + } + + for _, test := range tests { + format := "FindExecutable %#v in %#v as %#v" + name := fmt.Sprintf(format, test.files, test.paths, test.expected) + t.Run(name, func(t *testing.T) { + actual, err := FindExecutable(test.files, test.paths) + assert.NoError(t, err) + assert.Equal(t, test.expected, actual) + }) + } +} diff --git a/internal/chezmoi/findexecutable_windows_test.go b/internal/chezmoi/findexecutable_windows_test.go new file mode 100644 index 00000000000..fc6a72e4a53 --- /dev/null +++ b/internal/chezmoi/findexecutable_windows_test.go @@ -0,0 +1,101 @@ +//go:build windows + +package chezmoi + +import ( + "fmt" + "strings" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestFindExecutable(t *testing.T) { + tests := []struct { + files []string + paths []string + expected string + }{ + { + files: []string{"powershell.exe"}, + paths: []string{ + "c:\\windows\\system32", + "c:\\windows\\system64", + "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0", + }, + expected: "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }, + { + files: []string{"powershell"}, + paths: []string{ + "c:\\windows\\system32", + "c:\\windows\\system64", + "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0", + }, + expected: "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }, + { + files: []string{"weakshell.exe"}, + paths: []string{ + "c:\\windows\\system32", + "c:\\windows\\system64", + "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0", + }, + expected: "", + }, + { + files: []string{"weakshell"}, + paths: []string{ + "c:\\windows\\system32", + "c:\\windows\\system64", + "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0", + }, + expected: "", + }, + { + files: []string{"weakshell.exe", "powershell.exe"}, + paths: []string{ + "c:\\windows\\system32", + "c:\\windows\\system64", + "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0", + }, + expected: "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }, + { + files: []string{"weakshell", "powershell"}, + paths: []string{ + "c:\\windows\\system32", + "c:\\windows\\system64", + "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0", + }, + expected: "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }, + { + files: []string{"weakshell.exe", "chezmoishell.exe"}, + paths: []string{ + "c:\\windows\\system32", + "c:\\windows\\system64", + "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0", + }, + expected: "", + }, + { + files: []string{"weakshell", "chezmoishell"}, + paths: []string{ + "c:\\windows\\system32", + "c:\\windows\\system64", + "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0", + }, + expected: "", + }, + } + + for _, test := range tests { + name := fmt.Sprintf("FindExecutable %v in %#v as %v", test.files, test.paths, test.expected) + t.Run(name, func(t *testing.T) { + actual, err := FindExecutable(test.files, test.paths) + assert.NoError(t, err) + assert.Equal(t, strings.ToLower(test.expected), strings.ToLower(actual)) + }) + } +} diff --git a/internal/chezmoi/format.go b/internal/chezmoi/format.go index bc71e176737..9c28d1ee442 100644 --- a/internal/chezmoi/format.go +++ b/internal/chezmoi/format.go @@ -2,96 +2,103 @@ package chezmoi import ( "bytes" - "compress/gzip" "encoding/json" + "errors" + "fmt" "io" "strings" - "github.com/pelletier/go-toml" - "go.uber.org/multierr" + "github.com/pelletier/go-toml/v2" + "github.com/tailscale/hujson" "gopkg.in/yaml.v3" + + "github.com/twpayne/chezmoi/v2/internal/chezmoimaps" ) // Formats. var ( - FormatJSON Format = formatJSON{} - FormatTOML Format = formatTOML{} - FormatYAML Format = formatYAML{} + FormatJSON Format = formatJSON{} + FormatJSONC Format = formatJSONC{} + FormatTOML Format = formatTOML{} + FormatYAML Format = formatYAML{} ) +var errExpectedEOF = errors.New("expected EOF") + // A Format is a serialization format. type Format interface { - Marshal(value interface{}) ([]byte, error) + Marshal(value any) ([]byte, error) Name() string - Unmarshal(data []byte, value interface{}) error + Unmarshal(data []byte, value any) error } -// A formatGzippedJSON implements the gzipped JSON serialization format. -type formatGzippedJSON struct{} - // A formatJSON implements the JSON serialization format. type formatJSON struct{} +// A formatJSONC implements the JSONC serialization format. +type formatJSONC struct{} + // A formatTOML implements the TOML serialization format. type formatTOML struct{} // A formatYAML implements the YAML serialization format. type formatYAML struct{} -// Formats is a map of all Formats by name. -var Formats = map[string]Format{ - "json": FormatJSON, - "toml": FormatTOML, - "yaml": FormatYAML, -} - -// Marshal implements Format.Marshal. -func (formatGzippedJSON) Marshal(value interface{}) ([]byte, error) { - jsonData, err := json.Marshal(value) - if err != nil { - return nil, err +var ( + // FormatsByName is a map of all FormatsByName by name. + FormatsByName = map[string]Format{ + "jsonc": FormatJSONC, + "json": FormatJSON, + "toml": FormatTOML, + "yaml": FormatYAML, } - builder := &strings.Builder{} - builder.Grow(len(jsonData)) - gzipWriter := gzip.NewWriter(builder) - if _, err := gzipWriter.Write(jsonData); err != nil { - return nil, err + + // FormatsByExtension is a map of all Formats by extension. + FormatsByExtension = map[string]Format{ + "jsonc": FormatJSONC, + "json": FormatJSON, + "toml": FormatTOML, + "yaml": FormatYAML, + "yml": FormatYAML, } - if err := gzipWriter.Close(); err != nil { + FormatExtensions = chezmoimaps.SortedKeys(FormatsByExtension) +) + +// Marshal implements Format.Marshal. +func (formatJSONC) Marshal(value any) ([]byte, error) { + var builder strings.Builder + encoder := json.NewEncoder(&builder) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { return nil, err } - return []byte(builder.String()), nil + return hujson.Format([]byte(builder.String())) } // Name implements Format.Name. -func (formatGzippedJSON) Name() string { - return "json.gz" +func (formatJSONC) Name() string { + return "jsonc" } -// Unmask implements Format.Unmarshal. -func (formatGzippedJSON) Unmarshal(data []byte, value interface{}) (err error) { - var r *gzip.Reader - if r, err = gzip.NewReader(bytes.NewReader(data)); err != nil { - return - } - defer func() { - err = multierr.Append(err, r.Close()) - }() - jsonData, err := io.ReadAll(r) +// Unmarshal implements Format.Unmarshal. +func (formatJSONC) Unmarshal(data []byte, value any) error { + data, err := hujson.Standardize(data) if err != nil { - return + return err } - err = json.Unmarshal(jsonData, value) - return + return FormatJSON.Unmarshal(data, value) } // Marshal implements Format.Marshal. -func (formatJSON) Marshal(value interface{}) ([]byte, error) { - data, err := json.MarshalIndent(value, "", " ") - if err != nil { +func (formatJSON) Marshal(value any) ([]byte, error) { + var builder strings.Builder + encoder := json.NewEncoder(&builder) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { return nil, err } - return append(data, '\n'), nil + return []byte(builder.String()), nil } // Name implements Format.Name. @@ -100,12 +107,33 @@ func (formatJSON) Name() string { } // Unmarshal implements Format.Unmarshal. -func (formatJSON) Unmarshal(data []byte, value interface{}) error { - return json.Unmarshal(data, value) +func (formatJSON) Unmarshal(data []byte, value any) error { + switch value := value.(type) { + case *any, *[]any, *map[string]any: + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(value); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return errExpectedEOF + } + switch value := value.(type) { + case *any: + *value = replaceJSONNumbersWithNumericValues(*value) + case *[]any: + *value = replaceJSONNumbersWithNumericValuesSlice(*value) + case *map[string]any: + *value = replaceJSONNumbersWithNumericValuesMap(*value) + } + return nil + default: + return json.Unmarshal(data, value) + } } // Marshal implements Format.Marshal. -func (formatTOML) Marshal(value interface{}) ([]byte, error) { +func (formatTOML) Marshal(value any) ([]byte, error) { return toml.Marshal(value) } @@ -115,12 +143,12 @@ func (formatYAML) Name() string { } // Unmarshal implements Format.Unmarshal. -func (formatTOML) Unmarshal(data []byte, value interface{}) error { +func (formatTOML) Unmarshal(data []byte, value any) error { return toml.Unmarshal(data, value) } // Marshal implements Format.Marshal. -func (formatYAML) Marshal(value interface{}) ([]byte, error) { +func (formatYAML) Marshal(value any) ([]byte, error) { return yaml.Marshal(value) } @@ -130,6 +158,82 @@ func (formatTOML) Name() string { } // Unmarshal implements Format.Unmarshal. -func (formatYAML) Unmarshal(data []byte, value interface{}) error { +func (formatYAML) Unmarshal(data []byte, value any) error { return yaml.Unmarshal(data, value) } + +// FormatFromAbsPath returns the expected format of absPath. +func FormatFromAbsPath(absPath AbsPath) (Format, error) { + format, err := formatFromExtension(absPath.Ext()) + if err != nil { + return nil, fmt.Errorf("%s: %w", absPath, err) + } + return format, nil +} + +// formatFromExtension returns the expected format of absPath. +func formatFromExtension(extension string) (Format, error) { + format, ok := FormatsByExtension[strings.TrimPrefix(extension, ".")] + if !ok { + return nil, fmt.Errorf("%s: unknown format", extension) + } + return format, nil +} + +func isPrefixDotFormat(name, prefix string) bool { + for extension := range FormatsByExtension { + if name == prefix+"."+extension { + return true + } + } + return false +} + +func isPrefixDotFormatDotTmpl(name, prefix string) bool { + for extension := range FormatsByExtension { + if name == prefix+"."+extension+TemplateSuffix { + return true + } + } + return false +} + +// replaceJSONNumbersWithNumericValues replaces any json.Numbers in value with +// int64s or float64s if possible and returns the new value. If value is a slice +// or a map then it is mutated in place. +func replaceJSONNumbersWithNumericValues(value any) any { + switch value := value.(type) { + case json.Number: + if int64Value, err := value.Int64(); err == nil { + return int64Value + } + if float64Value, err := value.Float64(); err == nil { + return float64Value + } + // If value cannot be represented as an int64 or a float64 then return + // it as a string to preserve its value. Such values are valid JSON but + // are unlikely to occur in practice. See + // https://www.rfc-editor.org/rfc/rfc7159#section-6. + return value.String() + case []any: + return replaceJSONNumbersWithNumericValuesSlice(value) + case map[string]any: + return replaceJSONNumbersWithNumericValuesMap(value) + default: + return value + } +} + +func replaceJSONNumbersWithNumericValuesMap(value map[string]any) map[string]any { + for k, v := range value { + value[k] = replaceJSONNumbersWithNumericValues(v) + } + return value +} + +func replaceJSONNumbersWithNumericValuesSlice(value []any) []any { + for i, e := range value { + value[i] = replaceJSONNumbersWithNumericValues(e) + } + return value +} diff --git a/internal/chezmoi/format_test.go b/internal/chezmoi/format_test.go index 178ffa87022..2bba1385885 100644 --- a/internal/chezmoi/format_test.go +++ b/internal/chezmoi/format_test.go @@ -3,14 +3,22 @@ package chezmoi import ( "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/alecthomas/assert/v2" ) +func TestFormatJSONSingleValue(t *testing.T) { + var value any + assert.NoError(t, FormatJSON.Unmarshal([]byte(`{}`), &value)) + assert.NoError(t, FormatJSON.Unmarshal([]byte(`{} `), &value)) + assert.Error(t, FormatJSON.Unmarshal([]byte(`{} 1`), &value)) +} + func TestFormats(t *testing.T) { - assert.Contains(t, Formats, "json") - assert.Contains(t, Formats, "toml") - assert.Contains(t, Formats, "yaml") + assert.NotZero(t, FormatsByName["json"]) + assert.NotZero(t, FormatsByName["jsonc"]) + assert.NotZero(t, FormatsByName["toml"]) + assert.NotZero(t, FormatsByName["yaml"]) + assert.Zero(t, FormatsByName["yml"]) } func TestFormatRoundTrip(t *testing.T) { @@ -18,13 +26,12 @@ func TestFormatRoundTrip(t *testing.T) { Bool bool Bytes []byte Int int - Float float64 - Object map[string]interface{} + Object map[string]any String string } for _, format := range []Format{ - formatGzippedJSON{}, + formatJSONC{}, formatJSON{}, formatTOML{}, formatYAML{}, @@ -34,16 +41,15 @@ func TestFormatRoundTrip(t *testing.T) { Bool: true, Bytes: []byte("bytes"), Int: 1, - Float: 2.3, - Object: map[string]interface{}{ + Object: map[string]any{ "key": "value", }, String: "string", } data, err := format.Marshal(v) - require.NoError(t, err) + assert.NoError(t, err) var actualValue value - require.NoError(t, format.Unmarshal(data, &actualValue)) + assert.NoError(t, format.Unmarshal(data, &actualValue)) assert.Equal(t, v, actualValue) }) } diff --git a/internal/chezmoi/gitdiffsystem.go b/internal/chezmoi/gitdiffsystem.go index e12207af4e3..7189ac80e93 100644 --- a/internal/chezmoi/gitdiffsystem.go +++ b/internal/chezmoi/gitdiffsystem.go @@ -6,28 +6,36 @@ import ( "io/fs" "os/exec" "runtime" + "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" - vfs "github.com/twpayne/go-vfs/v4" + vfs "github.com/twpayne/go-vfs/v5" ) +// A TextConvFunc converts the contents of a file into a more human-readable form. +type TextConvFunc func(string, []byte) ([]byte, error) + // A GitDiffSystem wraps a System and logs all of the actions executed as a git // diff. type GitDiffSystem struct { system System dirAbsPath AbsPath - include *EntryTypeSet + filter *EntryTypeFilter reverse bool + scriptContents bool + textConvFunc TextConvFunc unifiedEncoder *diff.UnifiedEncoder } -// GetDiffSystemOptions are options for NewGitDiffSystem. +// GitDiffSystemOptions are options for NewGitDiffSystem. type GitDiffSystemOptions struct { - Color bool - Include *EntryTypeSet - Reverse bool + Color bool + Filter *EntryTypeFilter + Reverse bool + ScriptContents bool + TextConvFunc TextConvFunc } // NewGitDiffSystem returns a new GitDiffSystem. Output is written to w, the @@ -41,8 +49,10 @@ func NewGitDiffSystem(system System, w io.Writer, dirAbsPath AbsPath, options *G return &GitDiffSystem{ system: system, dirAbsPath: dirAbsPath, - include: options.Include, + filter: options.Filter, reverse: options.Reverse, + scriptContents: options.ScriptContents, + textConvFunc: options.TextConvFunc, unifiedEncoder: unifiedEncoder, } } @@ -53,7 +63,7 @@ func (s *GitDiffSystem) Chmod(name AbsPath, mode fs.FileMode) error { if err != nil { return err } - if s.include.IncludeFileInfo(fromInfo) { + if s.filter.IncludeFileInfo(fromInfo) { toMode := fromInfo.Mode().Type() | mode var toData []byte if fromInfo.Mode().IsRegular() { @@ -69,25 +79,20 @@ func (s *GitDiffSystem) Chmod(name AbsPath, mode fs.FileMode) error { return s.system.Chmod(name, mode) } +// Chtimes implements system.Chtimes. +func (s *GitDiffSystem) Chtimes(name AbsPath, atime, mtime time.Time) error { + return s.system.Chtimes(name, atime, mtime) +} + // Glob implements System.Glob. func (s *GitDiffSystem) Glob(pattern string) ([]string, error) { return s.system.Glob(pattern) } -// IdempotentCmdCombinedOutput implements System.IdempotentCmdCombinedOutput. -func (s *GitDiffSystem) IdempotentCmdCombinedOutput(cmd *exec.Cmd) ([]byte, error) { - return s.system.IdempotentCmdCombinedOutput(cmd) -} - -// IdempotentCmdOutput implements System.IdempotentCmdOutput. -func (s *GitDiffSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { - return s.system.IdempotentCmdOutput(cmd) -} - // Link implements System.Link. -func (s *GitDiffSystem) Link(oldname, newname AbsPath) error { +func (s *GitDiffSystem) Link(oldName, newName AbsPath) error { // LATER generate a diff - return s.system.Link(oldname, newname) + return s.system.Link(oldName, newName) } // Lstat implements System.Lstat. @@ -97,7 +102,7 @@ func (s *GitDiffSystem) Lstat(name AbsPath) (fs.FileInfo, error) { // Mkdir implements System.Mkdir. func (s *GitDiffSystem) Mkdir(name AbsPath, perm fs.FileMode) error { - if s.include.Include(EntryTypeDirs) { + if s.filter.IncludeEntryTypeBits(EntryTypeDirs) { if err := s.encodeDiff(name, nil, fs.ModeDir|perm); err != nil { return err } @@ -125,9 +130,17 @@ func (s *GitDiffSystem) Readlink(name AbsPath) (string, error) { return s.system.Readlink(name) } +// Remove implements System.Remove. +func (s *GitDiffSystem) Remove(name AbsPath) error { + if err := s.encodeDiff(name, nil, 0); err != nil { + return err + } + return s.system.Remove(name) +} + // RemoveAll implements System.RemoveAll. func (s *GitDiffSystem) RemoveAll(name AbsPath) error { - if s.include.Include(EntryTypeRemove) { + if s.filter.IncludeEntryTypeBits(EntryTypeRemove) { if err := s.encodeDiff(name, nil, 0); err != nil { return err } @@ -136,19 +149,19 @@ func (s *GitDiffSystem) RemoveAll(name AbsPath) error { } // Rename implements System.Rename. -func (s *GitDiffSystem) Rename(oldpath, newpath AbsPath) error { - fromFileInfo, err := s.Stat(oldpath) +func (s *GitDiffSystem) Rename(oldPath, newPath AbsPath) error { + fromFileInfo, err := s.Stat(oldPath) if err != nil { return err } - if s.include.IncludeFileInfo(fromFileInfo) { + if s.filter.IncludeFileInfo(fromFileInfo) { var fileMode filemode.FileMode var hash plumbing.Hash switch { case fromFileInfo.Mode().IsDir(): hash = plumbing.ZeroHash // LATER be more intelligent here case fromFileInfo.Mode().IsRegular(): - data, err := s.system.ReadFile(oldpath) + data, err := s.system.ReadFile(oldPath) if err != nil { return err } @@ -156,7 +169,7 @@ func (s *GitDiffSystem) Rename(oldpath, newpath AbsPath) error { default: fileMode = filemode.FileMode(fromFileInfo.Mode()) } - fromPath, toPath := s.trimPrefix(oldpath), s.trimPrefix(newpath) + fromPath, toPath := s.trimPrefix(oldPath), s.trimPrefix(newPath) if s.reverse { fromPath, toPath = toPath, fromPath } @@ -179,7 +192,7 @@ func (s *GitDiffSystem) Rename(oldpath, newpath AbsPath) error { return err } } - return s.system.Rename(oldpath, newpath) + return s.system.Rename(oldPath, newPath) } // RunCmd implements System.RunCmd. @@ -187,20 +200,23 @@ func (s *GitDiffSystem) RunCmd(cmd *exec.Cmd) error { return s.system.RunCmd(cmd) } -// RunIdempotentCmd implements System.RunIdempotentCmd. -func (s *GitDiffSystem) RunIdempotentCmd(cmd *exec.Cmd) error { - return s.system.RunIdempotentCmd(cmd) -} - // RunScript implements System.RunScript. -func (s *GitDiffSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte, interpreter *Interpreter) error { - if s.include.Include(EntryTypeScripts) { - mode := fs.FileMode(filemode.Executable) +func (s *GitDiffSystem) RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) error { + bits := EntryTypeScripts + if options.Condition == ScriptConditionAlways { + bits |= EntryTypeAlways + } + if s.filter.IncludeEntryTypeBits(bits) { fromData, toData := []byte(nil), data + fromMode, toMode := fs.FileMode(0), fs.FileMode(filemode.Executable) + if !s.scriptContents { + toData = nil + } if s.reverse { fromData, toData = toData, fromData + fromMode, toMode = toMode, fromMode } - diffPatch, err := DiffPatch(scriptname, fromData, mode, toData, mode) + diffPatch, err := DiffPatch(scriptName, fromData, fromMode, toData, toMode) if err != nil { return err } @@ -208,7 +224,7 @@ func (s *GitDiffSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte, return err } } - return s.system.RunScript(scriptname, dir, data, interpreter) + return s.system.RunScript(scriptName, dir, data, options) } // Stat implements System.Stat. @@ -223,7 +239,7 @@ func (s *GitDiffSystem) UnderlyingFS() vfs.FS { // WriteFile implements System.WriteFile. func (s *GitDiffSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMode) error { - if s.include.Include(EntryTypeFiles) { + if s.filter.IncludeEntryTypeBits(EntryTypeFiles) { if err := s.encodeDiff(filename, data, perm); err != nil { return err } @@ -232,18 +248,18 @@ func (s *GitDiffSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMod } // WriteSymlink implements System.WriteSymlink. -func (s *GitDiffSystem) WriteSymlink(oldname string, newname AbsPath) error { - if s.include.Include(EntryTypeSymlinks) { - toData := append([]byte(normalizeLinkname(oldname)), '\n') +func (s *GitDiffSystem) WriteSymlink(oldName string, newName AbsPath) error { + if s.filter.IncludeEntryTypeBits(EntryTypeSymlinks) { + toData := append([]byte(normalizeLinkname(oldName)), '\n') toMode := fs.ModeSymlink if runtime.GOOS == "windows" { toMode |= 0o666 } - if err := s.encodeDiff(newname, toData, toMode); err != nil { + if err := s.encodeDiff(newName, toData, toMode); err != nil { return err } } - return s.system.WriteSymlink(oldname, newname) + return s.system.WriteSymlink(oldName, newName) } // encodeDiff encodes the diff between the actual state of absPath and the @@ -253,6 +269,7 @@ func (s *GitDiffSystem) encodeDiff(absPath AbsPath, toData []byte, toMode fs.Fil var fromMode fs.FileMode switch fromInfo, err := s.system.Lstat(absPath); { case errors.Is(err, fs.ErrNotExist): + // Leave fromData and fromMode at their zero values. case err != nil: return err case fromInfo.Mode().IsRegular(): @@ -260,6 +277,12 @@ func (s *GitDiffSystem) encodeDiff(absPath AbsPath, toData []byte, toMode fs.Fil if err != nil { return err } + if s.textConvFunc != nil { + fromData, err = s.textConvFunc(absPath.String(), fromData) + if err != nil { + return err + } + } fromMode = fromInfo.Mode() case fromInfo.Mode().Type() == fs.ModeSymlink: fromDataStr, err := s.system.Readlink(absPath) @@ -272,14 +295,24 @@ func (s *GitDiffSystem) encodeDiff(absPath AbsPath, toData []byte, toMode fs.Fil fromMode = fromInfo.Mode() } + if s.textConvFunc != nil { + var err error + toData, err = s.textConvFunc(absPath.String(), toData) + if err != nil { + return err + } + } + if s.reverse { fromData, toData = toData, fromData fromMode, toMode = toMode, fromMode } + diffPatch, err := DiffPatch(s.trimPrefix(absPath), fromData, fromMode, toData, toMode) if err != nil { return err } + return s.unifiedEncoder.Encode(diffPatch) } diff --git a/internal/chezmoi/github.go b/internal/chezmoi/github.go new file mode 100644 index 00000000000..7ddce9f9573 --- /dev/null +++ b/internal/chezmoi/github.go @@ -0,0 +1,31 @@ +package chezmoi + +import ( + "context" + "net/http" + "os" + + "github.com/google/go-github/v63/github" + "golang.org/x/oauth2" +) + +// NewGitHubClient returns a new github.Client configured with an access token +// and a http client, if available. +func NewGitHubClient(ctx context.Context, httpClient *http.Client) *github.Client { + for _, key := range []string{ + "CHEZMOI_GITHUB_ACCESS_TOKEN", + "CHEZMOI_GITHUB_TOKEN", + "GITHUB_ACCESS_TOKEN", + "GITHUB_TOKEN", + } { + if accessToken := os.Getenv(key); accessToken != "" { + httpClient = oauth2.NewClient( + context.WithValue(ctx, oauth2.HTTPClient, httpClient), + oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: accessToken, + })) + break + } + } + return github.NewClient(httpClient) +} diff --git a/internal/chezmoi/glob.go b/internal/chezmoi/glob.go new file mode 100644 index 00000000000..7129ec677d1 --- /dev/null +++ b/internal/chezmoi/glob.go @@ -0,0 +1,37 @@ +package chezmoi + +import ( + "io/fs" + + "github.com/bmatcuk/doublestar/v4" + vfs "github.com/twpayne/go-vfs/v5" +) + +// A lstatFS implements io/fs.StatFS but uses Lstat instead of Stat. +type lstatFS struct { + wrapped interface { + fs.FS + Lstat(name string) (fs.FileInfo, error) + } +} + +// Open implements io/fs.StatFS.Open. +func (s lstatFS) Open(name string) (fs.File, error) { + return s.wrapped.Open(name) +} + +// Stat implements io/fs.StatFS.Stat. +func (s lstatFS) Stat(name string) (fs.FileInfo, error) { + return s.wrapped.Lstat(name) +} + +// Glob is like github.com/bmatcuk/doublestar/v4.Glob except that it does not +// follow symlinks. +func Glob(fileSystem vfs.FS, prefix string) ([]string, error) { + fsys := lstatFS{wrapped: fileSystem} + opts := []doublestar.GlobOption{ + doublestar.WithFailOnIOErrors(), + doublestar.WithNoFollow(), + } + return doublestar.Glob(fsys, prefix, opts...) +} diff --git a/internal/chezmoi/gpgencryption.go b/internal/chezmoi/gpgencryption.go index 9fceaa2c073..65c97cf45b1 100644 --- a/internal/chezmoi/gpgencryption.go +++ b/internal/chezmoi/gpgencryption.go @@ -1,25 +1,26 @@ package chezmoi import ( + "log/slog" "os" "os/exec" "runtime" - "go.uber.org/multierr" - + "github.com/twpayne/chezmoi/v2/internal/chezmoierrors" "github.com/twpayne/chezmoi/v2/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 - Suffix string + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + Recipient string `json:"recipient" mapstructure:"recipient" yaml:"recipient"` + Recipients []string `json:"recipients" mapstructure:"recipients" yaml:"recipients"` + Symmetric bool `json:"symmetric" mapstructure:"symmetric" yaml:"symmetric"` + Suffix string `json:"suffix" mapstructure:"suffix" yaml:"suffix"` } -// Decrypt implements Encyrption.Decrypt. +// Decrypt implements Encryption.Decrypt. func (e *GPGEncryption) Decrypt(ciphertext []byte) ([]byte, error) { var plaintext []byte if err := withPrivateTempDir(func(tempDirAbsPath AbsPath) error { @@ -44,13 +45,13 @@ func (e *GPGEncryption) Decrypt(ciphertext []byte) ([]byte, error) { } // DecryptToFile implements Encryption.DecryptToFile. -func (e *GPGEncryption) DecryptToFile(plaintextFilename AbsPath, ciphertext []byte) error { +func (e *GPGEncryption) DecryptToFile(plaintextAbsPath AbsPath, ciphertext []byte) error { return withPrivateTempDir(func(tempDirAbsPath AbsPath) error { ciphertextAbsPath := tempDirAbsPath.JoinString("ciphertext" + e.EncryptedSuffix()) if err := os.WriteFile(ciphertextAbsPath.String(), ciphertext, 0o600); err != nil { return err } - args := e.decryptArgs(plaintextFilename, ciphertextAbsPath) + args := e.decryptArgs(plaintextAbsPath, ciphertextAbsPath) return e.run(args) }) } @@ -80,12 +81,12 @@ func (e *GPGEncryption) Encrypt(plaintext []byte) ([]byte, error) { } // EncryptFile implements Encryption.EncryptFile. -func (e *GPGEncryption) EncryptFile(plaintextFilename AbsPath) ([]byte, error) { +func (e *GPGEncryption) EncryptFile(plaintextAbsPath AbsPath) ([]byte, error) { var ciphertext []byte if err := withPrivateTempDir(func(tempDirAbsPath AbsPath) error { ciphertextAbsPath := tempDirAbsPath.JoinString("ciphertext" + e.EncryptedSuffix()) - args := e.encryptArgs(plaintextFilename, ciphertextAbsPath) + args := e.encryptArgs(plaintextAbsPath, ciphertextAbsPath) if err := e.run(args); err != nil { return err } @@ -105,40 +106,44 @@ func (e *GPGEncryption) EncryptedSuffix() string { } // decryptArgs returns the arguments for decryption. -func (e *GPGEncryption) decryptArgs(plaintextFilename, ciphertextFilename AbsPath) []string { - args := []string{"--output", plaintextFilename.String()} +func (e *GPGEncryption) decryptArgs(plaintextAbsPath, ciphertextAbsPath AbsPath) []string { + args := []string{"--output", plaintextAbsPath.String()} args = append(args, e.Args...) - args = append(args, "--decrypt", ciphertextFilename.String()) + args = append(args, "--decrypt", ciphertextAbsPath.String()) return args } // encryptArgs returns the arguments for encryption. -func (e *GPGEncryption) encryptArgs(plaintextFilename, ciphertextFilename AbsPath) []string { +func (e *GPGEncryption) encryptArgs(plaintextAbsPath, ciphertextAbsPath AbsPath) []string { args := []string{ "--armor", - "--output", ciphertextFilename.String(), + "--output", ciphertextAbsPath.String(), } if e.Symmetric { args = append(args, "--symmetric") - } else if e.Recipient != "" { - args = append(args, "--recipient", e.Recipient) + } else { + if e.Recipient != "" { + args = append(args, "--recipient", e.Recipient) + } + for _, recipient := range e.Recipients { + args = append(args, "--recipient", recipient) + } } args = append(args, e.Args...) if !e.Symmetric { args = append(args, "--encrypt") } - args = append(args, plaintextFilename.String()) + args = append(args, plaintextAbsPath.String()) return args } // run runs the command with args. func (e *GPGEncryption) run(args []string) error { - //nolint:gosec - cmd := exec.Command(e.Command, args...) + cmd := exec.Command(e.Command, args...) //nolint:gosec cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - return chezmoilog.LogCmdRun(cmd) + return chezmoilog.LogCmdRun(slog.Default(), cmd) } // withPrivateTempDir creates a private temporary and calls f. @@ -147,9 +152,9 @@ func withPrivateTempDir(f func(tempDirAbsPath AbsPath) error) (err error) { if tempDir, err = os.MkdirTemp("", "chezmoi-encryption"); err != nil { return } - defer func() { - err = multierr.Append(err, os.RemoveAll(tempDir)) - }() + defer chezmoierrors.CombineFunc(&err, func() error { + return os.RemoveAll(tempDir) + }) if runtime.GOOS != "windows" { if err = os.Chmod(tempDir, 0o700); err != nil { return diff --git a/internal/chezmoi/gpgencryption_test.go b/internal/chezmoi/gpgencryption_test.go index 6d1fd9a16e7..1463b4a6bde 100644 --- a/internal/chezmoi/gpgencryption_test.go +++ b/internal/chezmoi/gpgencryption_test.go @@ -1,19 +1,23 @@ package chezmoi import ( + "runtime" "testing" - "github.com/stretchr/testify/require" + "github.com/alecthomas/assert/v2" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) func TestGPGEncryption(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping gpg tests on Windows") + } command := lookPathOrSkip(t, "gpg") tempDir := t.TempDir() key, passphrase, err := chezmoitest.GPGGenerateKey(command, tempDir) - require.NoError(t, err) + assert.NoError(t, err) for _, tc := range []struct { name string diff --git a/internal/chezmoi/hexbytes.go b/internal/chezmoi/hexbytes.go index 084556dc23d..67dd115bdea 100644 --- a/internal/chezmoi/hexbytes.go +++ b/internal/chezmoi/hexbytes.go @@ -29,8 +29,7 @@ func (h *HexBytes) UnmarshalText(text []byte) error { return nil } result := make([]byte, hex.DecodedLen(len(text))) - _, err := hex.Decode(result, text) - if err != nil { + if _, err := hex.Decode(result, text); err != nil { return err } *h = result diff --git a/internal/chezmoi/hexbytes_test.go b/internal/chezmoi/hexbytes_test.go index abe748f4e52..39c86e9b1bc 100644 --- a/internal/chezmoi/hexbytes_test.go +++ b/internal/chezmoi/hexbytes_test.go @@ -4,8 +4,7 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/alecthomas/assert/v2" ) func TestHexBytes(t *testing.T) { @@ -33,10 +32,10 @@ func TestHexBytes(t *testing.T) { } { t.Run(format.Name(), func(t *testing.T) { actual, err := format.Marshal(tc.b) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, []byte(tc.expectedStr), actual) var actualHexBytes HexBytes - require.NoError(t, format.Unmarshal(actual, &actualHexBytes)) + assert.NoError(t, format.Unmarshal(actual, &actualHexBytes)) assert.Equal(t, tc.b, actualHexBytes) }) } diff --git a/internal/chezmoi/interpreter.go b/internal/chezmoi/interpreter.go index 7e353c6fbf2..15036d2fdc7 100644 --- a/internal/chezmoi/interpreter.go +++ b/internal/chezmoi/interpreter.go @@ -1,15 +1,14 @@ package chezmoi import ( + "log/slog" "os/exec" - - "github.com/rs/zerolog" ) // An Interpreter interprets scripts. type Interpreter struct { - Command string `mapstructure:"command"` - Args []string `mapstructure:"args"` + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` } // ExecCommand returns the *exec.Cmd to interpret name. @@ -17,8 +16,7 @@ func (i *Interpreter) ExecCommand(name string) *exec.Cmd { if i.None() { return exec.Command(name) } - //nolint:gosec - return exec.Command(i.Command, append(i.Args, name)...) + return exec.Command(i.Command, append(i.Args, name)...) //nolint:gosec } // None returns if i represents no interpreter. @@ -26,16 +24,14 @@ func (i *Interpreter) None() bool { return i == nil || i.Command == "" } -// MarshalZerologObject implements -// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. -func (i *Interpreter) MarshalZerologObject(event *zerolog.Event) { - if i == nil { - return - } +// LogValue implements log/slog.LogValuer.LogValue. +func (i *Interpreter) LogValue() slog.Value { + var attrs []slog.Attr if i.Command != "" { - event.Str("command", i.Command) + attrs = append(attrs, slog.String("command", i.Command)) } if i.Args != nil { - event.Strs("args", i.Args) + attrs = append(attrs, slog.Any("args", i.Args)) } + return slog.GroupValue(attrs...) } diff --git a/internal/chezmoi/lazy.go b/internal/chezmoi/lazy.go index 13df1f3214d..c7b871358f5 100644 --- a/internal/chezmoi/lazy.go +++ b/internal/chezmoi/lazy.go @@ -1,9 +1,23 @@ package chezmoi +import "os/exec" + +// A commandFunc is a function that returns an *os/exec.Cmd. +type commandFunc func() *exec.Cmd + // A contentsFunc is a function that returns the contents of a file or an error. // It is typically used for lazy evaluation of a file's contents. type contentsFunc func() ([]byte, error) +// A lazyCommand returns an *os/exec.Cmd lazily. It is needed to defer the call +// to os/exec.Command because os/exec.Command calls os/exec.LookupPath and +// therefore depends on the state of $PATH when os/exec.Command is called, not +// the state of $PATH when os/exec.Cmd.{Run,Start} is called. +type lazyCommand struct { + commandFunc commandFunc + command *exec.Cmd +} + // A lazyContents evaluates its contents lazily. type lazyContents struct { contentsFunc contentsFunc @@ -24,6 +38,22 @@ type lazyLinkname struct { linknameSHA256 []byte } +// newLazyCommandFunc returns a new lazyCommand with commandFunc. +func newLazyCommandFunc(commandFunc func() *exec.Cmd) *lazyCommand { + return &lazyCommand{ + commandFunc: commandFunc, + } +} + +// Command returns lc's command. +func (lc *lazyCommand) Command() *exec.Cmd { + if lc.commandFunc != nil { + lc.command = lc.commandFunc() + lc.commandFunc = nil + } + return lc.command +} + // newLazyContents returns a new lazyContents with contents. func newLazyContents(contents []byte) *lazyContents { return &lazyContents{ @@ -32,7 +62,7 @@ func newLazyContents(contents []byte) *lazyContents { } // newLazyContentsFunc returns a new lazyContents with contentsFunc. -func newLazyContentsFunc(contentsFunc func() ([]byte, error)) *lazyContents { +func newLazyContentsFunc(contentsFunc contentsFunc) *lazyContents { return &lazyContents{ contentsFunc: contentsFunc, } diff --git a/internal/chezmoi/lookpath.go b/internal/chezmoi/lookpath.go new file mode 100644 index 00000000000..6461e3c0201 --- /dev/null +++ b/internal/chezmoi/lookpath.go @@ -0,0 +1,29 @@ +package chezmoi + +import ( + "os/exec" + "sync" +) + +var ( + lookPathCacheMutex sync.Mutex + lookPathCache = make(map[string]string) +) + +// LookPath is like os/exec.LookPath except that the first positive result is +// cached. +func LookPath(file string) (string, error) { + lookPathCacheMutex.Lock() + defer lookPathCacheMutex.Unlock() + + if path, ok := lookPathCache[file]; ok { + return path, nil + } + + path, err := exec.LookPath(file) + if err == nil { + lookPathCache[file] = path + } + + return path, err +} diff --git a/internal/chezmoi/mockpersistentstate.go b/internal/chezmoi/mockpersistentstate.go index 1ac20c3ea1f..a43103d01d1 100644 --- a/internal/chezmoi/mockpersistentstate.go +++ b/internal/chezmoi/mockpersistentstate.go @@ -30,7 +30,7 @@ func (s *MockPersistentState) CopyTo(p PersistentState) error { } // Data implements PersistentState.Data. -func (s *MockPersistentState) Data() (interface{}, error) { +func (s *MockPersistentState) Data() (any, error) { return s.buckets, nil } @@ -44,6 +44,12 @@ func (s *MockPersistentState) Delete(bucket, key []byte) error { return nil } +// DeleteBucket implements PersistentState.DeleteBucket. +func (s *MockPersistentState) DeleteBucket(bucket []byte) error { + delete(s.buckets, string(bucket)) + return nil +} + // ForEach implements PersistentState.ForEach. func (s *MockPersistentState) ForEach(bucket []byte, fn func(k, v []byte) error) error { for k, v := range s.buckets[string(bucket)] { diff --git a/internal/chezmoi/mockpersistentstate_test.go b/internal/chezmoi/mockpersistentstate_test.go index 4961f768aa4..4fe96a28da1 100644 --- a/internal/chezmoi/mockpersistentstate_test.go +++ b/internal/chezmoi/mockpersistentstate_test.go @@ -1,58 +1,11 @@ package chezmoi import ( - "io" "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestMockPersistentState(t *testing.T) { - var ( - bucket = []byte("bucket") - key = []byte("key") - value = []byte("value") - ) - - s1 := NewMockPersistentState() - - require.NoError(t, s1.Delete(bucket, value)) - - actualValue, err := s1.Get(bucket, key) - require.NoError(t, err) - assert.Nil(t, actualValue) - - require.NoError(t, s1.Set(bucket, key, value)) - - actualValue, err = s1.Get(bucket, key) - require.NoError(t, err) - assert.Equal(t, value, actualValue) - - require.NoError(t, s1.ForEach(bucket, func(k, v []byte) error { - assert.Equal(t, key, k) - assert.Equal(t, value, v) - return nil - })) - - assert.Equal(t, io.EOF, s1.ForEach(bucket, func(k, v []byte) error { - return io.EOF - })) - - s2 := NewMockPersistentState() - require.NoError(t, s1.CopyTo(s2)) - actualValue, err = s2.Get(bucket, key) - assert.NoError(t, err) - assert.Equal(t, value, actualValue) - - require.NoError(t, s1.Close()) - - actualValue, err = s1.Get(bucket, key) - assert.NoError(t, err) - assert.Equal(t, value, actualValue) - - require.NoError(t, s1.Delete(bucket, key)) - actualValue, err = s1.Get(bucket, key) - require.NoError(t, err) - assert.Nil(t, actualValue) + testPersistentState(t, func() PersistentState { + return NewMockPersistentState() + }) } diff --git a/internal/chezmoi/mode.go b/internal/chezmoi/mode.go index 88f45d17226..98866178873 100644 --- a/internal/chezmoi/mode.go +++ b/internal/chezmoi/mode.go @@ -16,6 +16,12 @@ func (e invalidModeError) Error() string { return "invalid mode: " + string(e) } +// ModeFlagCompletionFunc is a function that completes the value of mode flags. +var ModeFlagCompletionFunc = FlagCompletionFunc([]string{ + string(ModeFile), + string(ModeSymlink), +}) + // Set implements github.com/spf13/flag.Value.Set. func (m *Mode) Set(s string) error { switch Mode(s) { diff --git a/internal/chezmoi/nullpersistentstate.go b/internal/chezmoi/nullpersistentstate.go index 060fc978adc..09bbb33795d 100644 --- a/internal/chezmoi/nullpersistentstate.go +++ b/internal/chezmoi/nullpersistentstate.go @@ -11,11 +11,14 @@ func (NullPersistentState) Close() error { return nil } func (NullPersistentState) CopyTo(s PersistentState) error { return nil } // Data does nothing. -func (NullPersistentState) Data() (interface{}, error) { return nil, nil } +func (NullPersistentState) Data() (any, error) { return nil, nil } // Delete does nothing. func (NullPersistentState) Delete(bucket, key []byte) error { return nil } +// DeleteBucket does nothing. +func (NullPersistentState) DeleteBucket(bucket []byte) error { return nil } + // ForEach does nothing. func (NullPersistentState) ForEach(bucket []byte, fn func(k, v []byte) error) error { return nil } diff --git a/internal/chezmoi/nullsystem.go b/internal/chezmoi/nullsystem.go new file mode 100644 index 00000000000..ddb7033b370 --- /dev/null +++ b/internal/chezmoi/nullsystem.go @@ -0,0 +1,6 @@ +package chezmoi + +type NullSystem struct { + emptySystemMixin + noUpdateSystemMixin +} diff --git a/internal/chezmoi/nullsystem_test.go b/internal/chezmoi/nullsystem_test.go new file mode 100644 index 00000000000..fa049b01fc1 --- /dev/null +++ b/internal/chezmoi/nullsystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &NullSystem{} diff --git a/internal/chezmoi/path_unix.go b/internal/chezmoi/path_unix.go index 73c6e2acbeb..bc9db148674 100644 --- a/internal/chezmoi/path_unix.go +++ b/internal/chezmoi/path_unix.go @@ -1,5 +1,4 @@ -//go:build !windows -// +build !windows +//go:build unix package chezmoi @@ -8,18 +7,26 @@ import ( "strings" ) +var devNullAbsPath = NewAbsPath("/dev/null") + // NewAbsPathFromExtPath returns a new AbsPath by converting extPath to use // slashes, performing tilde expansion, and making the path absolute. func NewAbsPathFromExtPath(extPath string, homeDirAbsPath AbsPath) (AbsPath, error) { - tildeSlashPath := expandTilde(filepath.ToSlash(extPath), homeDirAbsPath) - if filepath.IsAbs(tildeSlashPath) { - return NewAbsPath(tildeSlashPath), nil - } - slashPathAbsPath, err := filepath.Abs(tildeSlashPath) - if err != nil { - return EmptyAbsPath, err + extPath = filepath.Clean(extPath) + switch { + case extPath == "~": + return homeDirAbsPath, nil + case strings.HasPrefix(extPath, "~/"): + return homeDirAbsPath.JoinString(extPath[2:]), nil + case filepath.IsAbs(extPath): + return NewAbsPath(extPath), nil + default: + absPath, err := filepath.Abs(extPath) + if err != nil { + return EmptyAbsPath, err + } + return NewAbsPath(absPath), nil } - return NewAbsPath(slashPathAbsPath), nil } // NormalizePath returns path normalized. On non-Windows systems, normalized @@ -32,18 +39,6 @@ func NormalizePath(path string) (AbsPath, error) { return NewAbsPath(absPath), nil } -// expandTilde expands a leading tilde in path. -func expandTilde(path string, homeDirAbsPath AbsPath) string { - switch { - case path == "~": - return homeDirAbsPath.String() - case strings.HasPrefix(path, "~/"): - return homeDirAbsPath.JoinString(path[2:]).String() - default: - return path - } -} - // normalizeLinkname returns linkname normalized. On non-Windows systems, it // returns linkname unchanged. func normalizeLinkname(linkname string) string { diff --git a/internal/chezmoi/path_windows.go b/internal/chezmoi/path_windows.go index 41986c5e458..0b1d5f17079 100644 --- a/internal/chezmoi/path_windows.go +++ b/internal/chezmoi/path_windows.go @@ -5,21 +5,27 @@ import ( "strings" ) +var devNullAbsPath = NewAbsPath("NUL:") + // NewAbsPathFromExtPath returns a new AbsPath by converting extPath to use // slashes, performing tilde expansion, making the path absolute, and converting // the volume name to uppercase. func NewAbsPathFromExtPath(extPath string, homeDirAbsPath AbsPath) (AbsPath, error) { - slashTildePath := filepath.ToSlash(expandTilde(extPath, homeDirAbsPath)) - if filepath.IsAbs(slashTildePath) { - return NewAbsPath(volumeNameToUpper(slashTildePath)), nil - } - tildeAbsPath, err := filepath.Abs(slashTildePath) - if err != nil { - return EmptyAbsPath, err + extPath = filepath.Clean(extPath) + switch { + case extPath == "~": + return homeDirAbsPath, nil + case len(extPath) >= 2 && extPath[0] == '~' && isSlash(extPath[1]): + return homeDirAbsPath.JoinString(filepath.ToSlash(extPath[2:])), nil + case filepath.IsAbs(extPath): + return NewAbsPath(volumeNameToUpper(extPath)).ToSlash(), nil + default: + extPath, err := filepath.Abs(extPath) + if err != nil { + return EmptyAbsPath, err + } + return NewAbsPath(volumeNameToUpper(extPath)).ToSlash(), nil } - // filepath.Abs on Windows converts forward slashes to backslashes so we - // have to call filepath.ToSlash again. - return NewAbsPath(filepath.ToSlash(volumeNameToUpper(tildeAbsPath))), nil } // NormalizePath returns path normalized. On Windows, normalized paths are @@ -36,18 +42,6 @@ func NormalizePath(path string) (AbsPath, error) { return NewAbsPath(path).ToSlash(), nil } -// expandTilde expands a leading tilde in path. -func expandTilde(path string, homeDirAbsPath AbsPath) string { - switch { - case path == "~": - return homeDirAbsPath.String() - case len(path) >= 2 && path[0] == '~' && isSlash(path[1]): - return homeDirAbsPath.JoinString(path[2:]).String() - default: - return path - } -} - // normalizeLinkname returns linkname normalized. On Windows, backslashes are // converted to forward slashes and if linkname is an absolute path then the // volume name is converted to uppercase. @@ -70,8 +64,7 @@ func volumeNameLen(path string) int { 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] != '.' { + 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. diff --git a/internal/chezmoi/path_windows_test.go b/internal/chezmoi/path_windows_test.go index fb295e1f7f1..b71b2315e6d 100644 --- a/internal/chezmoi/path_windows_test.go +++ b/internal/chezmoi/path_windows_test.go @@ -1,11 +1,52 @@ package chezmoi import ( + "strconv" "testing" - "github.com/stretchr/testify/assert" + "github.com/alecthomas/assert/v2" ) +func TestAbsPathTrimDirPrefix(t *testing.T) { + for i, tc := range []struct { + absPath AbsPath + dirPrefixAbsPath AbsPath + expected RelPath + }{ + { + absPath: NewAbsPath("/home/user/.config"), + dirPrefixAbsPath: NewAbsPath("/home/user"), + expected: NewRelPath(".config"), + }, + { + absPath: NewAbsPath("H:/.config"), + dirPrefixAbsPath: NewAbsPath("H:"), + expected: NewRelPath(".config"), + }, + { + absPath: NewAbsPath("H:/.config"), + dirPrefixAbsPath: NewAbsPath("H:/"), + expected: NewRelPath(".config"), + }, + { + absPath: NewAbsPath("H:/home/user/.config"), + dirPrefixAbsPath: NewAbsPath("H:/home/user"), + expected: NewRelPath(".config"), + }, + { + absPath: NewAbsPath(`//server/user/.config`), + dirPrefixAbsPath: NewAbsPath(`//server/user`), + expected: NewRelPath(".config"), + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + actual, err := tc.absPath.TrimDirPrefix(tc.dirPrefixAbsPath) + assert.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} + func TestNormalizeLinkname(t *testing.T) { for _, tc := range []struct { linkname string diff --git a/internal/chezmoi/pathstyle.go b/internal/chezmoi/pathstyle.go new file mode 100644 index 00000000000..daf3a9dba76 --- /dev/null +++ b/internal/chezmoi/pathstyle.go @@ -0,0 +1,50 @@ +package chezmoi + +import ( + "fmt" + "strings" +) + +type PathStyle string + +const ( + PathStyleAbsolute PathStyle = "absolute" + PathStyleRelative PathStyle = "relative" + PathStyleSourceAbsolute PathStyle = "source-absolute" + PathStyleSourceRelative PathStyle = "source-relative" +) + +var ( + PathStyleStrings = []string{ + PathStyleAbsolute.String(), + PathStyleRelative.String(), + PathStyleSourceAbsolute.String(), + PathStyleSourceRelative.String(), + } + + PathStyleFlagCompletionFunc = FlagCompletionFunc(PathStyleStrings) +) + +// Set implements github.com/spf13/pflag.Value.Set. +func (p *PathStyle) Set(s string) error { + uniqueAbbreviations := UniqueAbbreviations(PathStyleStrings) + pathStyleStr, ok := uniqueAbbreviations[s] + if !ok { + return fmt.Errorf("%s: unknown path style", s) + } + *p = PathStyle(pathStyleStr) + return nil +} + +func (p PathStyle) String() string { + return string(p) +} + +// Type implements github.com/spf13/pflag.Value.Type. +func (p PathStyle) Type() string { + return strings.Join(PathStyleStrings, "|") +} + +func (p PathStyle) Copy() *PathStyle { + return &p +} diff --git a/internal/chezmoi/patternset.go b/internal/chezmoi/patternset.go index 1a469a162fa..637a44b904b 100644 --- a/internal/chezmoi/patternset.go +++ b/internal/chezmoi/patternset.go @@ -2,62 +2,80 @@ package chezmoi import ( "fmt" + "log/slog" "path" "path/filepath" "sort" "github.com/bmatcuk/doublestar/v4" - "github.com/rs/zerolog" - vfs "github.com/twpayne/go-vfs/v4" + "github.com/twpayne/go-vfs/v5" + + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" +) + +type patternSetIncludeType bool + +const ( + patternSetInclude patternSetIncludeType = true + patternSetExclude patternSetIncludeType = false +) + +type patternSetMatchType int + +const ( + patternSetMatchInclude patternSetMatchType = 1 + patternSetMatchUnknown patternSetMatchType = 0 + patternSetMatchExclude patternSetMatchType = -1 ) // An patternSet is a set of patterns. type patternSet struct { - includePatterns stringSet - excludePatterns stringSet + includePatterns chezmoiset.Set[string] + excludePatterns chezmoiset.Set[string] } // newPatternSet returns a new patternSet. func newPatternSet() *patternSet { return &patternSet{ - includePatterns: newStringSet(), - excludePatterns: newStringSet(), + includePatterns: chezmoiset.New[string](), + excludePatterns: chezmoiset.New[string](), } } -// MarshalZerologObject implements -// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. -func (ps *patternSet) MarshalZerologObject(e *zerolog.Event) { +// LogValue implements log/slog.LogValuer.LogValue. +func (ps *patternSet) LogValue() slog.Value { if ps == nil { - return + return slog.Value{} } - e.Strs("includePatterns", ps.includePatterns.elements()) - e.Strs("excludePatterns", ps.excludePatterns.elements()) + return slog.GroupValue( + slog.Any("includePatterns", ps.includePatterns.Elements()), + slog.Any("excludePatterns", ps.excludePatterns.Elements()), + ) } // add adds a pattern to ps. -func (ps *patternSet) add(pattern string, include bool) error { +func (ps *patternSet) add(pattern string, include patternSetIncludeType) error { if ok := doublestar.ValidatePattern(pattern); !ok { return fmt.Errorf("%s: invalid pattern", pattern) } - if include { - ps.includePatterns.add(pattern) - } else { - ps.excludePatterns.add(pattern) + switch include { + case patternSetInclude: + ps.includePatterns.Add(pattern) + case patternSetExclude: + ps.excludePatterns.Add(pattern) } return nil } // glob returns all matches in fileSystem. func (ps *patternSet) glob(fileSystem vfs.FS, prefix string) ([]string, error) { - // FIXME use AbsPath and RelPath - allMatches := newStringSet() + allMatches := chezmoiset.New[string]() for includePattern := range ps.includePatterns { - matches, err := doublestar.Glob(fileSystem, prefix+includePattern) + matches, err := Glob(fileSystem, filepath.ToSlash(prefix+includePattern)) if err != nil { return nil, err } - allMatches.add(matches...) + allMatches.Add(matches...) } for match := range allMatches { for excludePattern := range ps.excludePatterns { @@ -66,29 +84,44 @@ func (ps *patternSet) glob(fileSystem vfs.FS, prefix string) ([]string, error) { return nil, err } if exclude { - allMatches.remove(match) + allMatches.Remove(match) } } } - matchesSlice := allMatches.elements() + matchesSlice := allMatches.Elements() for i, match := range matchesSlice { - matchesSlice[i] = mustTrimPrefix(filepath.ToSlash(match), prefix) + matchesSlice[i] = filepath.ToSlash(match)[len(prefix):] } sort.Strings(matchesSlice) return matchesSlice, nil } -// match returns if name matches any pattern in ps. -func (ps *patternSet) match(name string) bool { +// match returns if name matches ps. +func (ps *patternSet) match(name string) patternSetMatchType { + // If name is explicitly excluded, then return exclude. for pattern := range ps.excludePatterns { if ok, _ := doublestar.Match(pattern, name); ok { - return false + return patternSetMatchExclude } } + + // If name is explicitly included, then return include. for pattern := range ps.includePatterns { if ok, _ := doublestar.Match(pattern, name); ok { - return true + return patternSetMatchInclude } } - return false + + // If name did not match any include or exclude patterns... + switch { + case len(ps.includePatterns) > 0 && len(ps.excludePatterns) == 0: + // ...only include patterns were specified, so exclude by default. + return patternSetMatchExclude + case len(ps.includePatterns) == 0 && len(ps.excludePatterns) > 0: + // ...only exclude patterns were specified, so include by default. + return patternSetMatchInclude + default: + // ...both include and exclude were specified, so return unknown. + return patternSetMatchUnknown + } } diff --git a/internal/chezmoi/patternset_test.go b/internal/chezmoi/patternset_test.go index a5bc5588fe3..ede1a7ff998 100644 --- a/internal/chezmoi/patternset_test.go +++ b/internal/chezmoi/patternset_test.go @@ -3,9 +3,8 @@ package chezmoi import ( "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - vfs "github.com/twpayne/go-vfs/v4" + "github.com/alecthomas/assert/v2" + vfs "github.com/twpayne/go-vfs/v5" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -14,57 +13,57 @@ func TestPatternSet(t *testing.T) { for _, tc := range []struct { name string ps *patternSet - expectMatches map[string]bool + expectMatches map[string]patternSetMatchType }{ { name: "empty", ps: newPatternSet(), - expectMatches: map[string]bool{ - "foo": false, + expectMatches: map[string]patternSetMatchType{ + "foo": patternSetMatchUnknown, }, }, { name: "exact", - ps: mustNewPatternSet(t, map[string]bool{ - "foo": true, + ps: mustNewPatternSet(t, map[string]patternSetIncludeType{ + "foo": patternSetInclude, }), - expectMatches: map[string]bool{ - "foo": true, - "bar": false, + expectMatches: map[string]patternSetMatchType{ + "foo": patternSetMatchInclude, + "bar": patternSetMatchExclude, }, }, { name: "wildcard", - ps: mustNewPatternSet(t, map[string]bool{ - "b*": true, + ps: mustNewPatternSet(t, map[string]patternSetIncludeType{ + "b*": patternSetInclude, }), - expectMatches: map[string]bool{ - "foo": false, - "bar": true, - "baz": true, + expectMatches: map[string]patternSetMatchType{ + "foo": patternSetMatchExclude, + "bar": patternSetMatchInclude, + "baz": patternSetMatchInclude, }, }, { name: "exclude", - ps: mustNewPatternSet(t, map[string]bool{ - "b*": true, - "baz": false, + ps: mustNewPatternSet(t, map[string]patternSetIncludeType{ + "b*": patternSetInclude, + "baz": patternSetExclude, }), - expectMatches: map[string]bool{ - "foo": false, - "bar": true, - "baz": false, + expectMatches: map[string]patternSetMatchType{ + "foo": patternSetMatchUnknown, + "bar": patternSetMatchInclude, + "baz": patternSetMatchExclude, }, }, { name: "doublestar", - ps: mustNewPatternSet(t, map[string]bool{ - "**/foo": true, + ps: mustNewPatternSet(t, map[string]patternSetIncludeType{ + "**/foo": patternSetInclude, }), - expectMatches: map[string]bool{ - "foo": true, - "bar/foo": true, - "baz/bar/foo": true, + expectMatches: map[string]patternSetMatchType{ + "foo": patternSetMatchInclude, + "bar/foo": patternSetMatchInclude, + "baz/bar/foo": patternSetMatchInclude, }, }, } { @@ -80,7 +79,7 @@ func TestPatternSetGlob(t *testing.T) { for _, tc := range []struct { name string ps *patternSet - root interface{} + root any expectedMatches []string }{ { @@ -91,10 +90,10 @@ func TestPatternSetGlob(t *testing.T) { }, { name: "simple", - ps: mustNewPatternSet(t, map[string]bool{ - "/f*": true, + ps: mustNewPatternSet(t, map[string]patternSetIncludeType{ + "/f*": patternSetInclude, }), - root: map[string]interface{}{ + root: map[string]any{ "foo": "", }, expectedMatches: []string{ @@ -103,11 +102,11 @@ func TestPatternSetGlob(t *testing.T) { }, { name: "include_exclude", - ps: mustNewPatternSet(t, map[string]bool{ - "/b*": true, - "/*z": false, + ps: mustNewPatternSet(t, map[string]patternSetIncludeType{ + "/b*": patternSetInclude, + "/*z": patternSetExclude, }), - root: map[string]interface{}{ + root: map[string]any{ "bar": "", "baz": "", }, @@ -117,10 +116,10 @@ func TestPatternSetGlob(t *testing.T) { }, { name: "doublestar", - ps: mustNewPatternSet(t, map[string]bool{ - "/**/f*": true, + ps: mustNewPatternSet(t, map[string]patternSetIncludeType{ + "/**/f*": patternSetInclude, }), - root: map[string]interface{}{ + root: map[string]any{ "dir1/dir2/foo": "", }, expectedMatches: []string{ @@ -131,18 +130,18 @@ func TestPatternSetGlob(t *testing.T) { t.Run(tc.name, func(t *testing.T) { chezmoitest.WithTestFS(t, tc.root, func(fileSystem vfs.FS) { actualMatches, err := tc.ps.glob(fileSystem, "/") - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, tc.expectedMatches, actualMatches) }) }) } } -func mustNewPatternSet(t *testing.T, patterns map[string]bool) *patternSet { +func mustNewPatternSet(t *testing.T, patterns map[string]patternSetIncludeType) *patternSet { t.Helper() ps := newPatternSet() for pattern, include := range patterns { - require.NoError(t, ps.add(pattern, include)) + assert.NoError(t, ps.add(pattern, include)) } return ps } diff --git a/internal/chezmoi/persistentstate.go b/internal/chezmoi/persistentstate.go index bbc40887726..373e02500de 100644 --- a/internal/chezmoi/persistentstate.go +++ b/internal/chezmoi/persistentstate.go @@ -7,9 +7,13 @@ var ( // EntryStateBucket is the bucket for recording the entry states. EntryStateBucket = []byte("entryState") - // scriptStateBucket is the bucket for recording the state of run once + // GitRepoExternalStateBucket is the bucket for recording the state of commands + // that modify directories. + GitRepoExternalStateBucket = []byte("gitRepoExternalState") + + // ScriptStateBucket is the bucket for recording the state of run once // scripts. - scriptStateBucket = []byte("scriptState") + ScriptStateBucket = []byte("scriptState") stateFormat = formatJSON{} ) @@ -18,43 +22,19 @@ var ( type PersistentState interface { Close() error CopyTo(s PersistentState) error - Data() (interface{}, error) + Data() (any, error) Delete(bucket, key []byte) error + DeleteBucket(bucket []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) { - configStateData, err := persistentStateBucketData(s, ConfigStateBucket) - if err != nil { - return nil, err - } - entryStateData, err := persistentStateBucketData(s, EntryStateBucket) - if err != nil { - return nil, err - } - scriptStateData, err := persistentStateBucketData(s, scriptStateBucket) - if err != nil { - return nil, err - } - return struct { - ConfigState interface{} `json:"configState" toml:"configState" yaml:"configState"` - EntryState interface{} `json:"entryState" toml:"entryState" yaml:"entryState"` - ScriptState interface{} `json:"scriptState" toml:"scriptState" yaml:"scriptState"` - }{ - ConfigState: configStateData, - 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{}) +// PersistentStateBucketData returns the state data in bucket in s. +func PersistentStateBucketData(s PersistentState, bucket []byte) (map[string]any, error) { + result := make(map[string]any) if err := s.ForEach(bucket, func(k, v []byte) error { - var value map[string]interface{} + var value map[string]any if err := stateFormat.Unmarshal(v, &value); err != nil { return err } @@ -66,8 +46,21 @@ func persistentStateBucketData(s PersistentState, bucket []byte) (map[string]int 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) { +// PersistentStateData returns the structured data in s. +func PersistentStateData(s PersistentState, buckets map[string][]byte) (map[string]any, error) { + result := make(map[string]any) + for bucketName, bucketKey := range buckets { + stateData, err := PersistentStateBucketData(s, bucketKey) + if err != nil { + return nil, err + } + result[bucketName] = stateData + } + 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 any) (bool, error) { data, err := s.Get(bucket, key) if err != nil { return false, err @@ -81,8 +74,8 @@ func persistentStateGet(s PersistentState, bucket, key []byte, value interface{} return true, nil } -// persistentStateSet sets the value associated with key in bucket in s. -func persistentStateSet(s PersistentState, bucket, key []byte, value interface{}) error { +// PersistentStateSet sets the value associated with key in bucket in s. +func PersistentStateSet(s PersistentState, bucket, key []byte, value any) error { data, err := stateFormat.Marshal(value) if err != nil { return err diff --git a/internal/chezmoi/persistentstate_test.go b/internal/chezmoi/persistentstate_test.go new file mode 100644 index 00000000000..e5d69c0d702 --- /dev/null +++ b/internal/chezmoi/persistentstate_test.go @@ -0,0 +1,69 @@ +package chezmoi + +import ( + "io" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func testPersistentState(t *testing.T, constructor func() PersistentState) { + t.Helper() + + var ( + bucket1 = []byte("bucket1") + bucket2 = []byte("bucket2") + key = []byte("key1") + value = []byte("value") + ) + + s1 := constructor() + + assert.NoError(t, s1.Delete(bucket1, value)) + + actualValue, err := s1.Get(bucket1, key) + assert.NoError(t, err) + assert.Zero(t, actualValue) + + assert.NoError(t, s1.Set(bucket1, key, value)) + + actualValue, err = s1.Get(bucket1, key) + assert.NoError(t, err) + assert.Equal(t, value, actualValue) + + assert.NoError(t, s1.ForEach(bucket1, func(k, v []byte) error { + assert.Equal(t, key, k) + assert.Equal(t, value, v) + return nil + })) + + assert.Equal(t, io.EOF, s1.ForEach(bucket1, func(k, v []byte) error { + return io.EOF + })) + + s2 := constructor() + assert.NoError(t, s1.CopyTo(s2)) + actualValue, err = s2.Get(bucket1, key) + assert.NoError(t, err) + assert.Equal(t, value, actualValue) + + assert.NoError(t, s2.Close()) + + actualValue, err = s1.Get(bucket1, key) + assert.NoError(t, err) + assert.Equal(t, value, actualValue) + + assert.NoError(t, s1.Delete(bucket1, key)) + actualValue, err = s1.Get(bucket1, key) + assert.NoError(t, err) + assert.Zero(t, actualValue) + + assert.NoError(t, s1.Set(bucket2, key, value)) + actualValue, err = s1.Get(bucket2, key) + assert.NoError(t, err) + assert.Equal(t, value, actualValue) + assert.NoError(t, s1.DeleteBucket(bucket2)) + actualValue, err = s1.Get(bucket2, key) + assert.NoError(t, err) + assert.Zero(t, actualValue) +} diff --git a/internal/chezmoi/readonlysystem.go b/internal/chezmoi/readonlysystem.go index eed96f0e577..9114b73219c 100644 --- a/internal/chezmoi/readonlysystem.go +++ b/internal/chezmoi/readonlysystem.go @@ -2,9 +2,8 @@ package chezmoi import ( "io/fs" - "os/exec" - vfs "github.com/twpayne/go-vfs/v4" + vfs "github.com/twpayne/go-vfs/v5" ) // A ReadOnlySystem is a system that may only be read from. @@ -25,16 +24,6 @@ func (s *ReadOnlySystem) Glob(pattern string) ([]string, error) { return s.system.Glob(pattern) } -// IdempotentCmdCombinedOutput implements System.IdempotentCmdCombinedOutput. -func (s *ReadOnlySystem) IdempotentCmdCombinedOutput(cmd *exec.Cmd) ([]byte, error) { - return s.system.IdempotentCmdCombinedOutput(cmd) -} - -// 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) (fs.FileInfo, error) { return s.system.Lstat(filename) @@ -60,11 +49,6 @@ func (s *ReadOnlySystem) Readlink(name AbsPath) (string, error) { return s.system.Readlink(name) } -// RunIdempotentCmd implements System.RunIdempotentCmd. -func (s *ReadOnlySystem) RunIdempotentCmd(cmd *exec.Cmd) error { - return s.system.RunIdempotentCmd(cmd) -} - // Stat implements System.Stat. func (s *ReadOnlySystem) Stat(name AbsPath) (fs.FileInfo, error) { return s.system.Stat(name) diff --git a/internal/chezmoi/realsystem.go b/internal/chezmoi/realsystem.go index 62feb5a8561..11e8b41b298 100644 --- a/internal/chezmoi/realsystem.go +++ b/internal/chezmoi/realsystem.go @@ -3,38 +3,35 @@ package chezmoi import ( "errors" "io/fs" + "log/slog" "os" "os/exec" + "path/filepath" "runtime" + "time" - "github.com/bmatcuk/doublestar/v4" - vfs "github.com/twpayne/go-vfs/v4" - "go.uber.org/multierr" + vfs "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/chezmoi/v2/internal/chezmoierrors" "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) // A RealSystemOption sets an option on a RealSystem. type RealSystemOption func(*RealSystem) -// Glob implements System.Glob. -func (s *RealSystem) Glob(pattern string) ([]string, error) { - return doublestar.Glob(s.UnderlyingFS(), pattern) -} - -// IdempotentCmdCombinedOutput implements System.IdempotentCmdCombinedOutput. -func (s *RealSystem) IdempotentCmdCombinedOutput(cmd *exec.Cmd) ([]byte, error) { - return chezmoilog.LogCmdCombinedOutput(cmd) +// Chtimes implements System.Chtimes. +func (s *RealSystem) Chtimes(name AbsPath, atime, mtime time.Time) error { + return s.fileSystem.Chtimes(name.String(), atime, mtime) } -// IdempotentCmdOutput implements System.IdempotentCmdOutput. -func (s *RealSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { - return chezmoilog.LogCmdOutput(cmd) +// Glob implements System.Glob. +func (s *RealSystem) Glob(pattern string) ([]string, error) { + return Glob(s.UnderlyingFS(), filepath.ToSlash(pattern)) } // Link implements System.Link. -func (s *RealSystem) Link(oldname, newname AbsPath) error { - return s.fileSystem.Link(oldname.String(), newname.String()) +func (s *RealSystem) Link(oldName, newName AbsPath) error { + return s.fileSystem.Link(oldName.String(), newName.String()) } // Lstat implements System.Lstat. @@ -66,37 +63,48 @@ func (s *RealSystem) ReadFile(name AbsPath) ([]byte, error) { return s.fileSystem.ReadFile(name.String()) } +// Remove implements System.Remove. +func (s *RealSystem) Remove(name AbsPath) error { + return s.fileSystem.Remove(name.String()) +} + // RemoveAll implements System.RemoveAll. func (s *RealSystem) RemoveAll(name AbsPath) error { return s.fileSystem.RemoveAll(name.String()) } // Rename implements System.Rename. -func (s *RealSystem) Rename(oldpath, newpath AbsPath) error { - return s.fileSystem.Rename(oldpath.String(), newpath.String()) +func (s *RealSystem) Rename(oldPath, newPath AbsPath) error { + return s.fileSystem.Rename(oldPath.String(), newPath.String()) } // RunCmd implements System.RunCmd. func (s *RealSystem) RunCmd(cmd *exec.Cmd) error { - return chezmoilog.LogCmdRun(cmd) -} - -// RunIdempotentCmd implements System.RunIdempotentCmd. -func (s *RealSystem) RunIdempotentCmd(cmd *exec.Cmd) error { - return chezmoilog.LogCmdRun(cmd) + return chezmoilog.LogCmdRun(slog.Default(), cmd) } // RunScript implements System.RunScript. -func (s *RealSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte, interpreter *Interpreter) (err error) { +func (s *RealSystem) RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) (err error) { + // Create the script temporary directory, if needed. + s.createScriptTempDirOnce.Do(func() { + if !s.scriptTempDir.Empty() { + err = os.MkdirAll(s.scriptTempDir.String(), 0o700) + } + }) + if err != nil { + return err + } + // Write the temporary script file. Put the randomness at the front of the // filename to preserve any file extension for Windows scripts. - f, err := os.CreateTemp("", "*."+scriptname.Base()) + var f *os.File + f, err = os.CreateTemp(s.scriptTempDir.String(), "*."+scriptName.Base()) if err != nil { return } - defer func() { - err = multierr.Append(err, os.RemoveAll(f.Name())) - }() + defer chezmoierrors.CombineFunc(&err, func() error { + return os.RemoveAll(f.Name()) + }) // Make the script private before writing it in case it contains any // secrets. @@ -106,16 +114,19 @@ func (s *RealSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte, int } } _, err = f.Write(data) - err = multierr.Append(err, f.Close()) + err = chezmoierrors.Combine(err, f.Close()) if err != nil { return } - cmd := interpreter.ExecCommand(f.Name()) + cmd := options.Interpreter.ExecCommand(f.Name()) cmd.Dir, err = s.getScriptWorkingDir(dir) if err != nil { return err } + cmd.Env = append(os.Environ(), + "CHEZMOI_SOURCE_FILE="+options.SourceRelPath.String(), + ) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -136,7 +147,7 @@ func (s *RealSystem) UnderlyingFS() vfs.FS { // getScriptWorkingDir returns the script's working directory. // // If this is a before_ script then the requested working directory may not -// actually exist yet, so search through the parent directory hierarchy till +// actually exist yet, so search through the parent directory hierarchy until // we find a suitable working directory. func (s *RealSystem) getScriptWorkingDir(dir AbsPath) (string, error) { // This should always terminate because dir will eventually become ".", i.e. diff --git a/internal/chezmoi/realsystem_test.go b/internal/chezmoi/realsystem_test.go index 385f2235918..9ae2855a1d0 100644 --- a/internal/chezmoi/realsystem_test.go +++ b/internal/chezmoi/realsystem_test.go @@ -5,9 +5,8 @@ import ( "sort" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - vfs "github.com/twpayne/go-vfs/v4" + "github.com/alecthomas/assert/v2" + vfs "github.com/twpayne/go-vfs/v5" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -15,8 +14,8 @@ import ( var _ System = &RealSystem{} func TestRealSystemGlob(t *testing.T) { - chezmoitest.WithTestFS(t, map[string]interface{}{ - "/home/user": map[string]interface{}{ + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user": map[string]any{ "bar": "", "baz": "", "foo": "", @@ -55,7 +54,7 @@ func TestRealSystemGlob(t *testing.T) { } { t.Run(tc.pattern, func(t *testing.T) { actualMatches, err := system.Glob(tc.pattern) - require.NoError(t, err) + assert.NoError(t, err) sort.Strings(actualMatches) assert.Equal(t, tc.expectedMatches, pathsToSlashes(actualMatches)) }) diff --git a/internal/chezmoi/realsystem_unix.go b/internal/chezmoi/realsystem_unix.go index f0a879939e8..8527fa9ae64 100644 --- a/internal/chezmoi/realsystem_unix.go +++ b/internal/chezmoi/realsystem_unix.go @@ -1,5 +1,4 @@ -//go:build !windows -// +build !windows +//go:build unix package chezmoi @@ -7,19 +6,23 @@ import ( "errors" "io/fs" "os" + "sync" "syscall" "github.com/google/renameio/v2" - vfs "github.com/twpayne/go-vfs/v4" - "go.uber.org/multierr" + vfs "github.com/twpayne/go-vfs/v5" + + "github.com/twpayne/chezmoi/v2/internal/chezmoierrors" ) // An RealSystem is a System that writes to a filesystem and executes scripts. type RealSystem struct { - fileSystem vfs.FS - safe bool - devCache map[AbsPath]uint // devCache maps directories to device numbers. - tempDirCache map[uint]string // tempDirCache maps device numbers to renameio temporary directories. + fileSystem vfs.FS + safe bool + createScriptTempDirOnce sync.Once + scriptTempDir AbsPath + devCache map[AbsPath]uint // devCache maps directories to device numbers. + tempDirCache map[uint]string // tempDirCache maps device numbers to renameio temporary directories. } // RealSystemWithSafe sets the safe flag of the RealSystem. @@ -29,6 +32,13 @@ func RealSystemWithSafe(safe bool) RealSystemOption { } } +// RealSystemWithScriptTempDir sets the script temporary directory of the RealSystem. +func RealSystemWithScriptTempDir(scriptTempDir AbsPath) RealSystemOption { + return func(s *RealSystem) { + s.scriptTempDir = scriptTempDir + } +} + // NewRealSystem returns a System that acts on fileSystem. func NewRealSystem(fileSystem vfs.FS, options ...RealSystemOption) *RealSystem { s := &RealSystem{ @@ -83,9 +93,7 @@ func (s *RealSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMode) if t, err = renameio.TempFile(tempDir, filename.String()); err != nil { return } - defer func() { - err = multierr.Append(err, t.Cleanup()) - }() + defer chezmoierrors.CombineFunc(&err, t.Cleanup) if err = t.Chmod(perm); err != nil { return } @@ -100,16 +108,16 @@ func (s *RealSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMode) } // WriteSymlink implements System.WriteSymlink. -func (s *RealSystem) WriteSymlink(oldname string, newname AbsPath) error { +func (s *RealSystem) WriteSymlink(oldName string, newName AbsPath) error { // Special case: if writing to the real filesystem in safe mode, use // github.com/google/renameio. if s.safe && s.fileSystem == vfs.OSFS { - return renameio.Symlink(oldname, newname.String()) + return renameio.Symlink(oldName, newName.String()) } - if err := s.fileSystem.RemoveAll(newname.String()); err != nil && !errors.Is(err, fs.ErrNotExist) { + if err := s.fileSystem.RemoveAll(newName.String()); err != nil && !errors.Is(err, fs.ErrNotExist) { return err } - return s.fileSystem.Symlink(oldname, newname.String()) + return s.fileSystem.Symlink(oldName, newName.String()) } // writeFile is like os.WriteFile but always sets perm before writing data. @@ -121,13 +129,11 @@ func writeFile(fileSystem vfs.FS, filename AbsPath, data []byte, perm fs.FileMod if f, err = fileSystem.OpenFile(filename.String(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm); err != nil { return } - defer func() { - err = multierr.Append(err, f.Close()) - }() + defer chezmoierrors.CombineFunc(&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. + // in case the new contents contain private data after. if err = f.Chmod(perm); err != nil { return } diff --git a/internal/chezmoi/realsystem_windows.go b/internal/chezmoi/realsystem_windows.go index e1784bcf502..dff144edeb5 100644 --- a/internal/chezmoi/realsystem_windows.go +++ b/internal/chezmoi/realsystem_windows.go @@ -4,13 +4,16 @@ import ( "errors" "io/fs" "path/filepath" + "sync" - vfs "github.com/twpayne/go-vfs/v4" + vfs "github.com/twpayne/go-vfs/v5" ) // An RealSystem is a System that writes to a filesystem and executes scripts. type RealSystem struct { - fileSystem vfs.FS + fileSystem vfs.FS + createScriptTempDirOnce sync.Once + scriptTempDir AbsPath } // RealSystemWithSafe sets the safe flag of the RealSystem. On Windows it does @@ -21,11 +24,20 @@ func RealSystemWithSafe(safe bool) RealSystemOption { return func(s *RealSystem) {} } +// RealSystemWithScriptTempDir sets the script temporary directory of the RealSystem. +func RealSystemWithScriptTempDir(scriptTempDir AbsPath) RealSystemOption { + return func(s *RealSystem) {} +} + // NewRealSystem returns a System that acts on fs. func NewRealSystem(fileSystem vfs.FS, options ...RealSystemOption) *RealSystem { - return &RealSystem{ + s := &RealSystem{ fileSystem: fileSystem, } + for _, option := range options { + option(s) + } + return s } // Chmod implements System.Chmod. @@ -48,9 +60,9 @@ func (s *RealSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMode) } // WriteSymlink implements System.WriteSymlink. -func (s *RealSystem) WriteSymlink(oldname string, newname AbsPath) error { - if err := s.fileSystem.RemoveAll(newname.String()); err != nil && !errors.Is(err, fs.ErrNotExist) { +func (s *RealSystem) WriteSymlink(oldName string, newName AbsPath) error { + if err := s.fileSystem.RemoveAll(newName.String()); err != nil && !errors.Is(err, fs.ErrNotExist) { return err } - return s.fileSystem.Symlink(filepath.FromSlash(oldname), newname.String()) + return s.fileSystem.Symlink(filepath.FromSlash(oldName), newName.String()) } diff --git a/internal/chezmoi/recursivemerge.go b/internal/chezmoi/recursivemerge.go index e7cad2b3c56..3c9fe8d2aee 100644 --- a/internal/chezmoi/recursivemerge.go +++ b/internal/chezmoi/recursivemerge.go @@ -1,14 +1,14 @@ package chezmoi // recursiveCopy returns a recursive copy of v. -func recursiveCopy(v interface{}) interface{} { - m, ok := v.(map[string]interface{}) +func recursiveCopy(v any) any { + m, ok := v.(map[string]any) if !ok { return v } - c := make(map[string]interface{}) + c := make(map[string]any) for key, value := range m { - if mapValue, ok := value.(map[string]interface{}); ok { + if mapValue, ok := value.(map[string]any); ok { c[key] = recursiveCopy(mapValue) } else { c[key] = value @@ -18,19 +18,19 @@ func recursiveCopy(v interface{}) interface{} { } // RecursiveMerge recursively merges maps in source into dest. -func RecursiveMerge(dest, source map[string]interface{}) { +func RecursiveMerge(dest, source map[string]any) { for key, sourceValue := range source { destValue, ok := dest[key] if !ok { dest[key] = recursiveCopy(sourceValue) continue } - destMap, ok := destValue.(map[string]interface{}) + destMap, ok := destValue.(map[string]any) if !ok || destMap == nil { dest[key] = recursiveCopy(sourceValue) continue } - sourceMap, ok := sourceValue.(map[string]interface{}) + sourceMap, ok := sourceValue.(map[string]any) if !ok { dest[key] = recursiveCopy(sourceValue) continue diff --git a/internal/chezmoi/recursivemerge_test.go b/internal/chezmoi/recursivemerge_test.go index e1b49020ca7..750dc773825 100644 --- a/internal/chezmoi/recursivemerge_test.go +++ b/internal/chezmoi/recursivemerge_test.go @@ -3,44 +3,44 @@ package chezmoi import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/alecthomas/assert/v2" ) func TestRecursiveMerge(t *testing.T) { for _, tc := range []struct { - dest map[string]interface{} - source map[string]interface{} - expectedDest map[string]interface{} + dest map[string]any + source map[string]any + expectedDest map[string]any }{ { - dest: map[string]interface{}{}, + dest: map[string]any{}, source: nil, - expectedDest: map[string]interface{}{}, + expectedDest: map[string]any{}, }, { - dest: map[string]interface{}{ + dest: map[string]any{ "a": 1, "b": 2, - "c": map[string]interface{}{ + "c": map[string]any{ "d": 4, "e": 5, }, - "f": map[string]interface{}{ + "f": map[string]any{ "g": 6, }, }, - source: map[string]interface{}{ + source: map[string]any{ "b": 20, - "c": map[string]interface{}{ + "c": map[string]any{ "e": 50, "f": 60, }, "f": 60, }, - expectedDest: map[string]interface{}{ + expectedDest: map[string]any{ "a": 1, "b": 20, - "c": map[string]interface{}{ + "c": map[string]any{ "d": 4, "e": 50, "f": 60, @@ -55,12 +55,12 @@ func TestRecursiveMerge(t *testing.T) { } func TestRecursiveMergeCopies(t *testing.T) { - original := map[string]interface{}{ + original := map[string]any{ "key": "initialValue", } - dest := make(map[string]interface{}) + dest := make(map[string]any) RecursiveMerge(dest, original) - RecursiveMerge(dest, map[string]interface{}{ + RecursiveMerge(dest, map[string]any{ "key": "mergedValue", }) assert.Equal(t, "mergedValue", dest["key"]) diff --git a/internal/chezmoi/refreshexternals.go b/internal/chezmoi/refreshexternals.go new file mode 100644 index 00000000000..db0416d5e02 --- /dev/null +++ b/internal/chezmoi/refreshexternals.go @@ -0,0 +1,60 @@ +package chezmoi + +import ( + "fmt" + "strings" + + "github.com/twpayne/chezmoi/v2/internal/chezmoimaps" +) + +type RefreshExternals int + +const ( + RefreshExternalsAuto RefreshExternals = iota + RefreshExternalsAlways + RefreshExternalsNever +) + +var ( + refreshExternalsWellKnownStrings = map[string]RefreshExternals{ + "always": RefreshExternalsAlways, + "auto": RefreshExternalsAuto, + "never": RefreshExternalsNever, + } + + RefreshExternalsFlagCompletionFunc = FlagCompletionFunc(chezmoimaps.Keys(refreshExternalsWellKnownStrings)) +) + +func (re *RefreshExternals) Set(s string) error { + if value, ok := refreshExternalsWellKnownStrings[strings.ToLower(s)]; ok { + *re = value + return nil + } + switch value, err := ParseBool(s); { + case err != nil: + return err + case value: + *re = RefreshExternalsAlways + return nil + default: + *re = RefreshExternalsNever + return nil + } +} + +func (re RefreshExternals) String() string { + switch re { + case RefreshExternalsAlways: + return "always" + case RefreshExternalsAuto: + return "auto" + case RefreshExternalsNever: + return "never" + default: + panic(fmt.Sprintf("%d: invalid RefreshExternals value", re)) + } +} + +func (re RefreshExternals) Type() string { + return "always|auto|never" +} diff --git a/internal/chezmoi/relpath.go b/internal/chezmoi/relpath.go index abb0a632a9b..f36d41aa5ea 100644 --- a/internal/chezmoi/relpath.go +++ b/internal/chezmoi/relpath.go @@ -1,7 +1,6 @@ package chezmoi import ( - "encoding/json" "path" "strings" ) @@ -74,9 +73,9 @@ func (p RelPath) Join(relPaths ...RelPath) RelPath { // JoinString returns a new RelPath with ss appended. func (p RelPath) JoinString(ss ...string) RelPath { - strs := make([]string, 0, len(ss)+1) - strs = append(strs, p.relPath) - strs = append(strs, ss...) + strs := make([]string, len(ss)+1) + strs[0] = p.relPath + copy(strs[1:len(ss)+1], ss) return NewRelPath(path.Join(strs...)) } @@ -92,7 +91,7 @@ func (p RelPath) Less(other RelPath) bool { // MarshalJSON implements encoding.TextMarshaler.MarshalJSON. func (p RelPath) MarshalJSON() ([]byte, error) { - return json.Marshal(p.relPath) + return FormatJSON.Marshal(p.relPath) } // Slice returns a part of p. @@ -100,6 +99,16 @@ func (p RelPath) Slice(begin, end int) RelPath { return NewRelPath(p.relPath[begin:end]) } +// SourceRelPath returns p as a SourceRelPath. +func (p RelPath) SourceRelPath() SourceRelPath { + return NewSourceRelPath(p.relPath) +} + +// SourceRelDirPath returns p as a directory SourceRelPath. +func (p RelPath) SourceRelDirPath() SourceRelPath { + return NewSourceRelDirPath(p.relPath) +} + // Split returns p's directory and path. func (p RelPath) Split() (RelPath, RelPath) { dir, file := path.Split(p.relPath) @@ -109,9 +118,9 @@ func (p RelPath) Split() (RelPath, RelPath) { // SplitAll returns p's components. func (p RelPath) SplitAll() []RelPath { components := strings.Split(p.relPath, "/") - relPaths := make([]RelPath, 0, len(components)) - for _, component := range components { - relPaths = append(relPaths, NewRelPath(component)) + relPaths := make([]RelPath, len(components)) + for i, component := range components { + relPaths[i] = NewRelPath(component) } return relPaths } diff --git a/internal/chezmoi/sourcerelpath.go b/internal/chezmoi/sourcerelpath.go index 3691525d421..e942b233422 100644 --- a/internal/chezmoi/sourcerelpath.go +++ b/internal/chezmoi/sourcerelpath.go @@ -5,6 +5,8 @@ import ( "strings" ) +var emptySourceRelPath SourceRelPath + // A SourceRelPath is a relative path to an entry in the source state. type SourceRelPath struct { relPath RelPath @@ -41,9 +43,12 @@ func (p SourceRelPath) Empty() bool { // Join appends sourceRelPaths to p. func (p SourceRelPath) Join(sourceRelPaths ...SourceRelPath) SourceRelPath { - relPaths := make([]RelPath, 0, len(sourceRelPaths)) - for _, sourceRelPath := range sourceRelPaths { - relPaths = append(relPaths, sourceRelPath.relPath) + if len(sourceRelPaths) == 0 { + return p + } + relPaths := make([]RelPath, len(sourceRelPaths)) + for i, sourceRelPath := range sourceRelPaths { + relPaths[i] = sourceRelPath.relPath } return SourceRelPath{ relPath: p.relPath.Join(relPaths...), diff --git a/internal/chezmoi/sourcerelpath_test.go b/internal/chezmoi/sourcerelpath_test.go index 8d9052f5a8f..4fbc2e48fe2 100644 --- a/internal/chezmoi/sourcerelpath_test.go +++ b/internal/chezmoi/sourcerelpath_test.go @@ -3,7 +3,7 @@ package chezmoi import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/alecthomas/assert/v2" ) func TestSourceRelPath(t *testing.T) { diff --git a/internal/chezmoi/sourcestate.go b/internal/chezmoi/sourcestate.go index 114115bfcce..083737f9991 100644 --- a/internal/chezmoi/sourcestate.go +++ b/internal/chezmoi/sourcestate.go @@ -1,8 +1,6 @@ package chezmoi // FIXME implement externals in chezmoi source state format -// FIXME implement external git repos -// FIXME implement include and exclude entry type sets for externals import ( "bufio" @@ -13,22 +11,30 @@ import ( "fmt" "io" "io/fs" + "log/slog" "net/http" "net/url" "os" "os/exec" "path" + "path/filepath" + "regexp" "runtime" + "slices" "sort" "strings" + "sync" + "syscall" "text/template" "time" "github.com/coreos/go-semver/semver" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - vfs "github.com/twpayne/go-vfs/v4" - "go.uber.org/multierr" + "github.com/mitchellh/copystructure" + + "github.com/twpayne/chezmoi/v2/internal/chezmoierrors" + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" + "github.com/twpayne/chezmoi/v2/internal/chezmoimaps" + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" ) // An ExternalType is a type of external source. @@ -36,60 +42,108 @@ type ExternalType string // ExternalTypes. const ( - ExternalTypeArchive ExternalType = "archive" - ExternalTypeFile ExternalType = "file" + ExternalTypeArchive ExternalType = "archive" + ExternalTypeArchiveFile ExternalType = "archive-file" + ExternalTypeFile ExternalType = "file" + ExternalTypeGitRepo ExternalType = "git-repo" ) -// An External is an external source. -type External struct { - Type ExternalType `json:"type" toml:"type" yaml:"type"` - Encrypted bool `json:"encrypted" toml:"encrypted" yaml:"encrypted"` - Exact bool `json:"exact" toml:"exact" yaml:"exact"` - Executable bool `json:"executable" toml:"executable" yaml:"executable"` - Filter struct { - Command string `json:"command" toml:"command" yaml:"command"` - Args []string `json:"args" toml:"args" yaml:"args"` - } `json:"filter" toml:"filter" yaml:"filter"` - Format ArchiveFormat `json:"format" toml:"format" yaml:"format"` - RefreshPeriod time.Duration `json:"refreshPeriod" toml:"refreshPeriod" yaml:"refreshPeriod"` - StripComponents int `json:"stripComponents" toml:"stripComponents" yaml:"stripComponents"` - URL string `json:"url" toml:"url" yaml:"url"` +var ( + lineEndingRx = regexp.MustCompile(`(?m)(?:\r\n|\r|\n)`) + modifyTemplateRx = regexp.MustCompile(`(?m)^.*chezmoi:modify-template.*$(?:\r?\n)?`) + templateDirectiveRx = regexp.MustCompile(`(?m)^.*?chezmoi:template:(.*)$(?:\r?\n)?`) + templateDirectiveKeyValuePairRx = regexp.MustCompile(`\s*(\S+)=("(?:[^"]|\\")*"|\S+)`) + + // AppleDouble constants. + appleDoubleNamePrefix = "._" + appleDoubleContentsPrefix = []byte{0x00, 0x05, 0x16, 0x07, 0x00, 0x02, 0x00, 0x00} +) + +type externalArchive struct { + ExtractAppleDoubleFiles bool `json:"extractAppleDoubleFiles" toml:"extractAppleDoubleFiles" yaml:"extractAppleDoubleFiles"` +} + +type externalChecksum struct { + MD5 HexBytes `json:"md5" toml:"md5" yaml:"md5"` + RIPEMD160 HexBytes `json:"ripemd160" toml:"ripemd160" yaml:"ripemd160"` + SHA1 HexBytes `json:"sha1" toml:"sha1" yaml:"sha1"` + SHA256 HexBytes `json:"sha256" toml:"sha256" yaml:"sha256"` + SHA384 HexBytes `json:"sha384" toml:"sha384" yaml:"sha384"` + SHA512 HexBytes `json:"sha512" toml:"sha512" yaml:"sha512"` + Size int `json:"size" toml:"size" yaml:"size"` } -// A externalCacheEntry is an external cache entry. -type externalCacheEntry struct { - URL string `json:"url" toml:"url" time:"url"` - Time time.Time `json:"time" toml:"time" yaml:"time"` - Data []byte `json:"data" toml:"data" yaml:"data"` +type externalClone struct { + Args []string `json:"args" toml:"args" yaml:"args"` } -var externalCacheFormat = formatGzippedJSON{} +type externalFilter struct { + Command string `json:"command" toml:"command" yaml:"command"` + Args []string `json:"args" toml:"args" yaml:"args"` +} + +type externalPull struct { + Args []string `json:"args" toml:"args" yaml:"args"` +} + +// An External is an external source. +type External struct { + Type ExternalType `json:"type" toml:"type" yaml:"type"` + Encrypted bool `json:"encrypted" toml:"encrypted" yaml:"encrypted"` + Exact bool `json:"exact" toml:"exact" yaml:"exact"` + Executable bool `json:"executable" toml:"executable" yaml:"executable"` + Private bool `json:"private" toml:"private" yaml:"private"` + ReadOnly bool `json:"readonly" toml:"readonly" yaml:"readonly"` + Checksum externalChecksum `json:"checksum" toml:"checksum" yaml:"checksum"` + Clone externalClone `json:"clone" toml:"clone" yaml:"clone"` + Decompress compressionFormat `json:"decompress" toml:"decompress" yaml:"decompress"` + Exclude []string `json:"exclude" toml:"exclude" yaml:"exclude"` + Filter externalFilter `json:"filter" toml:"filter" yaml:"filter"` + Format ArchiveFormat `json:"format" toml:"format" yaml:"format"` + Archive externalArchive `json:"archive" toml:"archive" yaml:"archive"` + Include []string `json:"include" toml:"include" yaml:"include"` + ArchivePath string `json:"path" toml:"path" yaml:"path"` + Pull externalPull `json:"pull" toml:"pull" yaml:"pull"` + RefreshPeriod Duration `json:"refreshPeriod" toml:"refreshPeriod" yaml:"refreshPeriod"` + StripComponents int `json:"stripComponents" toml:"stripComponents" yaml:"stripComponents"` + URL string `json:"url" toml:"url" yaml:"url"` + sourceAbsPath AbsPath +} // A SourceState is a source state. type SourceState struct { + sync.Mutex root sourceStateEntryTreeNode + removeDirs chezmoiset.Set[RelPath] baseSystem System system System sourceDirAbsPath AbsPath destDirAbsPath AbsPath cacheDirAbsPath AbsPath + createScriptTempDirOnce sync.Once + scriptTempDirAbsPath AbsPath umask fs.FileMode encryption Encryption ignore *patternSet - interpreters map[string]*Interpreter + remove *patternSet + interpreters map[string]Interpreter httpClient *http.Client - logger *zerolog.Logger - minVersion semver.Version + logger *slog.Logger + version semver.Version mode Mode - defaultTemplateDataFunc func() map[string]interface{} + defaultTemplateDataFunc func() map[string]any + templateDataOnly bool readTemplateData bool - userTemplateData map[string]interface{} - priorityTemplateData map[string]interface{} - templateData map[string]interface{} + readTemplates bool + defaultTemplateData map[string]any + userTemplateData map[string]any + priorityTemplateData map[string]any + templateData map[string]any templateFuncs template.FuncMap templateOptions []string - templates map[string]*template.Template - externals map[RelPath]External + templates map[string]*Template + externals map[RelPath][]*External + ignoredRelPaths chezmoiset.Set[RelPath] } // A SourceStateOption sets an option on a source state. @@ -109,6 +163,13 @@ func WithCacheDir(cacheDirAbsPath AbsPath) SourceStateOption { } } +// WithDefaultTemplateDataFunc sets the default template data function. +func WithDefaultTemplateDataFunc(defaultTemplateDataFunc func() map[string]any) SourceStateOption { + return func(s *SourceState) { + s.defaultTemplateDataFunc = defaultTemplateDataFunc + } +} + // WithDestDir sets the destination directory. func WithDestDir(destDirAbsPath AbsPath) SourceStateOption { return func(s *SourceState) { @@ -131,14 +192,14 @@ func WithHTTPClient(httpClient *http.Client) SourceStateOption { } // WithInterpreters sets the interpreters. -func WithInterpreters(interpreters map[string]*Interpreter) SourceStateOption { +func WithInterpreters(interpreters map[string]Interpreter) SourceStateOption { return func(s *SourceState) { s.interpreters = interpreters } } // WithLogger sets the logger. -func WithLogger(logger *zerolog.Logger) SourceStateOption { +func WithLogger(logger *slog.Logger) SourceStateOption { return func(s *SourceState) { s.logger = logger } @@ -152,7 +213,7 @@ func WithMode(mode Mode) SourceStateOption { } // WithPriorityTemplateData adds priority template data. -func WithPriorityTemplateData(priorityTemplateData map[string]interface{}) SourceStateOption { +func WithPriorityTemplateData(priorityTemplateData map[string]any) SourceStateOption { return func(s *SourceState) { RecursiveMerge(s.priorityTemplateData, priorityTemplateData) } @@ -165,6 +226,20 @@ func WithReadTemplateData(readTemplateData bool) SourceStateOption { } } +// WithReadTemplates sets whether to read .chezmoitemplates directories. +func WithReadTemplates(readTemplates bool) SourceStateOption { + return func(s *SourceState) { + s.readTemplates = readTemplates + } +} + +// WithScriptTempDir sets the source directory. +func WithScriptTempDir(scriptDirAbsPath AbsPath) SourceStateOption { + return func(s *SourceState) { + s.scriptTempDirAbsPath = scriptDirAbsPath + } +} + // WithSourceDir sets the source directory. func WithSourceDir(sourceDirAbsPath AbsPath) SourceStateOption { return func(s *SourceState) { @@ -179,10 +254,10 @@ func WithSystem(system System) SourceStateOption { } } -// WithDefaultTemplateDataFunc sets the default template data function. -func WithDefaultTemplateDataFunc(defaultTemplateDataFunc func() map[string]interface{}) SourceStateOption { +// WithTemplateDataOnly sets whether only template data should be read. +func WithTemplateDataOnly(templateDataOnly bool) SourceStateOption { return func(s *SourceState) { - s.defaultTemplateDataFunc = defaultTemplateDataFunc + s.templateDataOnly = templateDataOnly } } @@ -200,6 +275,20 @@ func WithTemplateOptions(templateOptions []string) SourceStateOption { } } +// WithUmask sets the umask. +func WithUmask(umask fs.FileMode) SourceStateOption { + return func(s *SourceState) { + s.umask = umask + } +} + +// WithVersion sets the version. +func WithVersion(version semver.Version) SourceStateOption { + return func(s *SourceState) { + s.version = version + } +} + // A targetStateEntryFunc returns a TargetStateEntry based on reading an AbsPath // on a System. type targetStateEntryFunc func(System, AbsPath) (TargetStateEntry, error) @@ -207,16 +296,21 @@ type targetStateEntryFunc func(System, AbsPath) (TargetStateEntry, error) // NewSourceState creates a new source state with the given options. func NewSourceState(options ...SourceStateOption) *SourceState { s := &SourceState{ + removeDirs: chezmoiset.New[RelPath](), umask: Umask, encryption: NoEncryption{}, ignore: newPatternSet(), + remove: newPatternSet(), httpClient: http.DefaultClient, - logger: &log.Logger, + logger: slog.Default(), readTemplateData: true, - priorityTemplateData: make(map[string]interface{}), - userTemplateData: make(map[string]interface{}), + readTemplates: true, + priorityTemplateData: make(map[string]any), + userTemplateData: make(map[string]any), templateOptions: DefaultTemplateOptions, - externals: make(map[RelPath]External), + templates: make(map[string]*Template), + externals: make(map[RelPath][]*External), + ignoredRelPaths: chezmoiset.New[RelPath](), } for _, option := range options { option(s) @@ -225,54 +319,101 @@ func NewSourceState(options ...SourceStateOption) *SourceState { } // A PreAddFunc is called before a new source state entry is added. -type PreAddFunc func(targetRelPath RelPath, newSourceStateEntry, oldSourceStateEntry SourceStateEntry) error +type PreAddFunc func(targetRelPath RelPath, fileInfo fs.FileInfo) error + +// A ReplaceFunc is called before a source state entry is replaced. +type ReplaceFunc func(targetRelPath RelPath, newSourceStateEntry, oldSourceStateEntry SourceStateEntry) error // AddOptions are options to SourceState.Add. type AddOptions struct { - AutoTemplate bool // Automatically create templates, if possible. - Create bool // Add create_ entries instead of normal entries. - Empty bool // Add the empty_ attribute to added files. - Encrypt bool // Encrypt files. - EncryptedSuffix string // Suffix for encrypted files. - Exact bool // Add the exact_ attribute to added directories. - Include *EntryTypeSet // Only add types in this set. - PreAddFunc PreAddFunc // Function to be called before the source entry is added. - RemoveDir RelPath // Directory to remove before adding. - Template bool // Add the .tmpl attribute to added files. - TemplateSymlinks bool // Add symlinks with targets in the source or home directories as templates. + AutoTemplate bool // Automatically create templates, if possible. + Create bool // Add create_ entries instead of normal entries. + Encrypt bool // Encrypt files. + EncryptedSuffix string // Suffix for encrypted files. + Errorf func(string, ...any) // Function to print errors. + Exact bool // Add the exact_ attribute to added directories. + Filter *EntryTypeFilter // Entry type filter. + OnIgnoreFunc func(RelPath) // Function to call when a target is ignored. + PreAddFunc PreAddFunc // Function to be called before a source entry is added. + ConfigFileAbsPath AbsPath // Path to config file. + ProtectedAbsPaths []AbsPath // Paths that must not be added. + RemoveDir RelPath // Directory to remove before adding. + ReplaceFunc ReplaceFunc // Function to be called before a source entry is replaced. + Template bool // Add the .tmpl attribute to added files. + TemplateSymlinks bool // Add symlinks with targets in the source or home directories as templates. } // Add adds destAbsPathInfos to s. func (s *SourceState) Add( - sourceSystem System, persistentState PersistentState, destSystem System, destAbsPathInfos map[AbsPath]fs.FileInfo, + sourceSystem System, + persistentState PersistentState, + destSystem System, + destAbsPathInfos map[AbsPath]fs.FileInfo, options *AddOptions, ) error { + // Filter out excluded and ignored paths. + destAbsPaths := AbsPaths(chezmoimaps.Keys(destAbsPathInfos)) + sort.Sort(destAbsPaths) + n := 0 + for _, destAbsPath := range destAbsPaths { + destAbsPathInfo := destAbsPathInfos[destAbsPath] + if !options.Filter.IncludeFileInfo(destAbsPathInfo) { + continue + } + + targetRelPath := destAbsPath.MustTrimDirPrefix(s.destDirAbsPath) + if s.Ignore(targetRelPath) { + if options.OnIgnoreFunc != nil { + options.OnIgnoreFunc(targetRelPath) + } + continue + } + + destAbsPaths[n] = destAbsPath + n++ + } + destAbsPaths = destAbsPaths[:n] + + // Check for protected paths. + for _, destAbsPath := range destAbsPaths { + if destAbsPath == options.ConfigFileAbsPath { + format := "%s: cannot add chezmoi's config file to chezmoi, use a config file template instead" + return fmt.Errorf(format, destAbsPath) + } + } + for _, destAbsPath := range destAbsPaths { + for _, protectedAbsPath := range options.ProtectedAbsPaths { + switch { + case protectedAbsPath.Empty(): + // Do nothing. + case strings.HasPrefix(destAbsPath.String(), protectedAbsPath.String()): + format := "%s: cannot add chezmoi file to chezmoi (%s is protected)" + return fmt.Errorf(format, destAbsPath, protectedAbsPath) + } + } + } + type sourceUpdate struct { destAbsPath AbsPath entryState *EntryState sourceRelPaths []SourceRelPath } - destAbsPaths := make(AbsPaths, 0, len(destAbsPathInfos)) - for destAbsPath := range destAbsPathInfos { - destAbsPaths = append(destAbsPaths, destAbsPath) - } - sort.Sort(destAbsPaths) - - sourceUpdates := make([]sourceUpdate, 0, len(destAbsPathInfos)) + sourceUpdates := make([]sourceUpdate, 0, len(destAbsPaths)) newSourceStateEntries := make(map[SourceRelPath]SourceStateEntry) newSourceStateEntriesByTargetRelPath := make(map[RelPath]SourceStateEntry) - nonEmptyDirs := make(map[SourceRelPath]struct{}) -DESTABSPATH: + nonEmptyDirs := chezmoiset.New[SourceRelPath]() + externalDirRelPaths := chezmoiset.New[RelPath]() + dirRenames := make(map[AbsPath]AbsPath) +DEST_ABS_PATH: for _, destAbsPath := range destAbsPaths { - destAbsPathInfo := destAbsPathInfos[destAbsPath] - if !options.Include.IncludeFileInfo(destAbsPathInfo) { - continue - } targetRelPath := destAbsPath.MustTrimDirPrefix(s.destDirAbsPath) - if s.Ignore(targetRelPath) { - continue + // Skip any entries in known external dirs. + for externalDir := range externalDirRelPaths { + if targetRelPath.HasDirPrefix(externalDir) { + continue DEST_ABS_PATH + } } // Find the target's parent directory in the source state. @@ -281,20 +422,45 @@ DESTABSPATH: parentSourceRelPath = SourceRelPath{} } else if parentEntry, ok := newSourceStateEntriesByTargetRelPath[targetParentRelPath]; ok { parentSourceRelPath = parentEntry.SourceRelPath() - } else if parentEntry := s.root.Get(targetParentRelPath); parentEntry != nil { - parentSourceRelPath = parentEntry.SourceRelPath() + } else if nodes := s.root.getNodes(targetParentRelPath); nodes != nil { + for i, node := range nodes { + if i == 0 { + // nodes[0].sourceStateEntry should always be nil because it + // refers to the destination directory, which is not manged. + // chezmoi manages the destination directory's contents, not + // the destination directory itself. For example, chezmoi + // does not set the name or permissions of the user's home + // directory. + if node.sourceStateEntry != nil { + panic(fmt.Errorf("nodes[0]: expected nil, got %+v", node.sourceStateEntry)) + } + continue + } + switch sourceStateDir, ok := node.sourceStateEntry.(*SourceStateDir); { + case i != len(nodes)-1 && !ok: + panic(fmt.Errorf("nodes[%d]: unexpected non-terminal source state entry, got %T", i, node.sourceStateEntry)) + case ok && sourceStateDir.Attr.External: + targetRelPathComponents := targetRelPath.SplitAll() + externalDirRelPath := EmptyRelPath.Join(targetRelPathComponents[:i]...) + externalDirRelPaths.Add(externalDirRelPath) + if options.Errorf != nil { + options.Errorf("%s: skipping entries in external_ directory\n", externalDirRelPath) + } + continue DEST_ABS_PATH + } + } + parentSourceRelPath = nodes[len(nodes)-1].sourceStateEntry.SourceRelPath() } else { return fmt.Errorf("%s: parent directory not in source state", destAbsPath) } - nonEmptyDirs[parentSourceRelPath] = struct{}{} + nonEmptyDirs.Add(parentSourceRelPath) + destAbsPathInfo := destAbsPathInfos[destAbsPath] actualStateEntry, err := NewActualStateEntry(destSystem, destAbsPath, destAbsPathInfo, nil) if err != nil { return err } - newSourceStateEntry, err := s.sourceStateEntry( - actualStateEntry, destAbsPath, destAbsPathInfo, parentSourceRelPath, options, - ) + newSourceStateEntry, err := s.sourceStateEntry(actualStateEntry, destAbsPath, destAbsPathInfo, parentSourceRelPath, options) if err != nil { return err } @@ -302,6 +468,15 @@ DESTABSPATH: continue } + if options.PreAddFunc != nil { + switch err := options.PreAddFunc(targetRelPath, destAbsPathInfo); { + case errors.Is(err, fs.SkipDir): + continue DEST_ABS_PATH + case err != nil: + return err + } + } + sourceEntryRelPath := newSourceStateEntry.SourceRelPath() entryState, err := actualStateEntry.EntryState() @@ -314,13 +489,13 @@ DESTABSPATH: sourceRelPaths: []SourceRelPath{sourceEntryRelPath}, } - if oldSourceStateEntry := s.root.Get(targetRelPath); oldSourceStateEntry != nil { + if oldSourceStateEntry := s.root.get(targetRelPath); oldSourceStateEntry != nil { oldSourceEntryRelPath := oldSourceStateEntry.SourceRelPath() if !oldSourceEntryRelPath.Empty() && oldSourceEntryRelPath != sourceEntryRelPath { - if options.PreAddFunc != nil { - switch err := options.PreAddFunc(targetRelPath, newSourceStateEntry, oldSourceStateEntry); { - case errors.Is(err, Skip): - continue DESTABSPATH + if options.ReplaceFunc != nil { + switch err := options.ReplaceFunc(targetRelPath, newSourceStateEntry, oldSourceStateEntry); { + case errors.Is(err, fs.SkipDir): + continue DEST_ABS_PATH case err != nil: return err } @@ -328,18 +503,21 @@ DESTABSPATH: // 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. + // directory's contents. _, 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) + oldSourceAbsPath := s.sourceDirAbsPath.Join(oldSourceEntryRelPath.RelPath()) + newSourceAbsPath := s.sourceDirAbsPath.Join(sourceEntryRelPath.RelPath()) + dirRenames[oldSourceAbsPath] = newSourceAbsPath + continue DEST_ABS_PATH + } + + // Otherwise, remove the old entry. + newSourceStateEntries[oldSourceEntryRelPath] = &SourceStateRemove{ + origin: SourceStateOriginRemove{}, } + update.sourceRelPaths = append(update.sourceRelPaths, oldSourceEntryRelPath) } } @@ -354,7 +532,7 @@ DESTABSPATH: if _, ok := sourceStateEntry.(*SourceStateDir); !ok { continue } - if _, ok := nonEmptyDirs[sourceEntryRelPath]; ok { + if nonEmptyDirs.Contains(sourceEntryRelPath) { continue } @@ -379,14 +557,14 @@ DESTABSPATH: var sourceRoot sourceStateEntryTreeNode for sourceRelPath, sourceStateEntry := range newSourceStateEntries { - sourceRoot.Set(sourceRelPath.RelPath(), sourceStateEntry) + sourceRoot.set(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 != EmptyRelPath { - _ = s.root.ForEach(EmptyRelPath, func(targetRelPath RelPath, sourceStateEntry SourceStateEntry) error { + _ = s.root.forEach(EmptyRelPath, func(targetRelPath RelPath, sourceStateEntry SourceStateEntry) error { if !targetRelPath.HasDirPrefix(options.RemoveDir) { return nil } @@ -394,7 +572,10 @@ DESTABSPATH: return nil } sourceRelPath := sourceStateEntry.SourceRelPath() - sourceRoot.Set(sourceRelPath.RelPath(), &SourceStateRemove{}) + sourceRoot.set(sourceRelPath.RelPath(), &SourceStateRemove{ + sourceRelPath: sourceRelPath, + targetRelPath: targetRelPath, + }) update := sourceUpdate{ destAbsPath: s.destDirAbsPath.Join(targetRelPath), entryState: &EntryState{ @@ -414,10 +595,14 @@ DESTABSPATH: for _, sourceUpdate := range sourceUpdates { for _, sourceRelPath := range sourceUpdate.sourceRelPaths { err := targetSourceState.Apply( - sourceSystem, sourceSystem, NullPersistentState{}, s.sourceDirAbsPath, sourceRelPath.RelPath(), + sourceSystem, + sourceSystem, + NullPersistentState{}, + s.sourceDirAbsPath, + sourceRelPath.RelPath(), ApplyOptions{ - Include: options.Include, - Umask: s.umask, + Filter: options.Filter, + Umask: s.umask, }, ) if err != nil { @@ -425,21 +610,39 @@ DESTABSPATH: } } if !sourceUpdate.destAbsPath.Empty() { - if err := persistentStateSet( - persistentState, EntryStateBucket, sourceUpdate.destAbsPath.Bytes(), sourceUpdate.entryState, - ); err != nil { + if err := PersistentStateSet(persistentState, EntryStateBucket, sourceUpdate.destAbsPath.Bytes(), sourceUpdate.entryState); err != nil { return err } } } + // Rename directories last because updates assume that directory names have + // not changed. Rename directories in reverse order so children are renamed + // before their parents. + oldDirAbsPaths := make([]AbsPath, 0, len(dirRenames)) + for oldDirAbsPath := range dirRenames { + oldDirAbsPaths = append(oldDirAbsPaths, oldDirAbsPath) + } + sort.Slice(oldDirAbsPaths, func(i, j int) bool { + return oldDirAbsPaths[j].Less(oldDirAbsPaths[i]) + }) + for _, oldDirAbsPath := range oldDirAbsPaths { + newDirAbsPath := dirRenames[oldDirAbsPath] + if err := sourceSystem.Rename(oldDirAbsPath, newDirAbsPath); err != nil { + return err + } + } + return nil } // AddDestAbsPathInfos adds an fs.FileInfo to destAbsPathInfos for destAbsPath // and any of its parents which are not already known. func (s *SourceState) AddDestAbsPathInfos( - destAbsPathInfos map[AbsPath]fs.FileInfo, system System, destAbsPath AbsPath, fileInfo fs.FileInfo, + destAbsPathInfos map[AbsPath]fs.FileInfo, + system System, + destAbsPath AbsPath, + fileInfo fs.FileInfo, ) error { for { if _, err := destAbsPath.TrimDirPrefix(s.destDirAbsPath); err != nil { @@ -464,7 +667,7 @@ func (s *SourceState) AddDestAbsPathInfos( return nil } parentRelPath := parentAbsPath.MustTrimDirPrefix(s.destDirAbsPath) - if s.root.Get(parentRelPath) != nil { + if _, ok := s.root.get(parentRelPath).(*SourceStateDir); ok { return nil } @@ -474,28 +677,27 @@ func (s *SourceState) AddDestAbsPathInfos( } // A PreApplyFunc is called before a target is applied. -type PreApplyFunc func( - targetRelPath RelPath, targetEntryState, lastWrittenEntryState, actualEntryState *EntryState, -) error +type PreApplyFunc func(targetRelPath RelPath, targetEntryState, lastWrittenEntryState, actualEntryState *EntryState) error // ApplyOptions are options to SourceState.ApplyAll and SourceState.ApplyOne. type ApplyOptions struct { - Include *EntryTypeSet + Filter *EntryTypeFilter PreApplyFunc PreApplyFunc Umask fs.FileMode } -// Apply updates targetRelPath in targetDir in destSystem to match s. +// Apply updates targetRelPath in targetDirAbsPath in destSystem to match s. func (s *SourceState) Apply( - targetSystem, destSystem System, persistentState PersistentState, targetDir AbsPath, targetRelPath RelPath, + targetSystem, destSystem System, + persistentState PersistentState, + targetDirAbsPath AbsPath, + targetRelPath RelPath, options ApplyOptions, ) error { - sourceStateEntry := s.root.Get(targetRelPath) + sourceStateEntry := s.root.get(targetRelPath) - if !options.Include.IncludeEncrypted() { - if sourceStateFile, ok := sourceStateEntry.(*SourceStateFile); ok && sourceStateFile.Attr.Encrypted { - return nil - } + if !options.Filter.IncludeSourceStateEntry(sourceStateEntry) { + return nil } destAbsPath := s.destDirAbsPath.Join(targetRelPath) @@ -504,11 +706,11 @@ func (s *SourceState) Apply( return err } - if options.Include != nil && !options.Include.IncludeTargetStateEntry(targetStateEntry) { + if !options.Filter.IncludeTargetStateEntry(targetStateEntry) { return nil } - targetAbsPath := targetDir.Join(targetRelPath) + targetAbsPath := targetDirAbsPath.Join(targetRelPath) targetEntryState, err := targetStateEntry.EntryState(options.Umask) if err != nil { @@ -530,7 +732,7 @@ func (s *SourceState) Apply( if options.PreApplyFunc != nil { var lastWrittenEntryState *EntryState var entryState EntryState - ok, err := persistentStateGet(persistentState, EntryStateBucket, targetAbsPath.Bytes(), &entryState) + ok, err := PersistentStateGet(persistentState, EntryStateBucket, targetAbsPath.Bytes(), &entryState) if err != nil { return err } @@ -543,60 +745,6 @@ func (s *SourceState) Apply( return err } - // Mitigate a bug in chezmoi before version 2.0.10 in a user-friendly - // way. - // - // chezmoi before version 2.0.10 incorrectly stored the last written - // entry state permissions, due to buggy umask handling. This caused - // chezmoi apply to raise a false positive that a file or directory had - // been modified since chezmoi last wrote it, since the permissions did - // not match. Further compounding the problem, the diff presented to the - // user was empty as the target state matched the actual state. - // - // The mitigation consists of several parts. First, detect that the bug - // as precisely as possible by detecting where the the target state, - // actual state, and last written entry state permissions match when the - // umask is considered. - // - // If this is the case, then patch the last written entry state as if - // the permissions were correctly stored. - // - // Finally, try to update the last written entry state in the persistent - // state so we don't hit this path the next time the user runs chezmoi - // apply. We ignore any errors because the persistent state might be in - // read-only or dry-run mode. - // - // FIXME remove this mitigation in a later version of chezmoi - switch { - case lastWrittenEntryState == nil: - case lastWrittenEntryState.Type == EntryStateTypeFile: - if targetStateFile, ok := targetStateEntry.(*TargetStateFile); ok { - if actualStateFile, ok := actualStateEntry.(*ActualStateFile); ok { - if actualStateFile.perm.Perm() == targetStateFile.perm.Perm() { - if targetStateFile.perm.Perm() != lastWrittenEntryState.Mode.Perm() { - if targetStateFile.perm.Perm() == lastWrittenEntryState.Mode.Perm()&^s.umask { - lastWrittenEntryState.Mode = targetStateFile.perm - _ = persistentStateSet(persistentState, EntryStateBucket, targetAbsPath.Bytes(), lastWrittenEntryState) - } - } - } - } - } - case lastWrittenEntryState.Type == EntryStateTypeDir: - if targetStateDir, ok := targetStateEntry.(*TargetStateDir); ok { - if actualStateDir, ok := actualStateEntry.(*ActualStateDir); ok { - if actualStateDir.perm.Perm() == targetStateDir.perm.Perm() { - if targetStateDir.perm.Perm() != lastWrittenEntryState.Mode.Perm() { - if targetStateDir.perm.Perm() == lastWrittenEntryState.Mode.Perm()&^s.umask { - lastWrittenEntryState.Mode = fs.ModeDir | targetStateDir.perm - _ = persistentStateSet(persistentState, EntryStateBucket, targetAbsPath.Bytes(), lastWrittenEntryState) - } - } - } - } - } - } - // If the target entry state matches the actual entry state, but not the // last written entry state then silently update the last written entry // state. This handles the case where the user makes identical edits to @@ -604,7 +752,7 @@ func (s *SourceState) Apply( // respect to the last written state, we record the effect of the last // apply as the last written state. if targetEntryState.Equivalent(actualEntryState) && !lastWrittenEntryState.Equivalent(actualEntryState) { - err := persistentStateSet(persistentState, EntryStateBucket, targetAbsPath.Bytes(), targetEntryState) + err := PersistentStateSet(persistentState, EntryStateBucket, targetAbsPath.Bytes(), targetEntryState) if err != nil { return err } @@ -623,12 +771,7 @@ func (s *SourceState) Apply( return nil } - return persistentStateSet(persistentState, EntryStateBucket, targetAbsPath.Bytes(), targetEntryState) -} - -// Contains returns the source state entry for targetRelPath. -func (s *SourceState) Contains(targetRelPath RelPath) bool { - return s.root.Get(targetRelPath) != nil + return PersistentStateSet(persistentState, EntryStateBucket, targetAbsPath.Bytes(), targetEntryState) } // Encryption returns s's encryption. @@ -636,67 +779,136 @@ func (s *SourceState) Encryption() Encryption { return s.encryption } +// ExecuteTemplateDataOptions are options to SourceState.ExecuteTemplateData. +type ExecuteTemplateDataOptions struct { + Name string + Destination string + Data []byte + TemplateOptions TemplateOptions +} + // 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)) +func (s *SourceState) ExecuteTemplateData(options ExecuteTemplateDataOptions) ([]byte, error) { + templateOptions := options.TemplateOptions + templateOptions.Options = slices.Clone(s.templateOptions) + + tmpl, err := ParseTemplate(options.Name, options.Data, s.templateFuncs, templateOptions) if err != nil { return nil, err } - for name, t := range s.templates { - tmpl, err = tmpl.AddParseTree(name, t.Tree) + for _, t := range s.templates { + tmpl, err = tmpl.AddParseTree(t) if err != nil { return nil, err } } - // Temporarily set .chezmoi.sourceFile to the name of the template. + // Set .chezmoi.sourceFile to the name of the template. templateData := s.TemplateData() - if chezmoiTemplateData, ok := templateData["chezmoi"].(map[string]interface{}); ok { - chezmoiTemplateData["sourceFile"] = name - defer delete(chezmoiTemplateData, "sourceFile") + if chezmoiTemplateData, ok := templateData["chezmoi"].(map[string]any); ok { + chezmoiTemplateData["sourceFile"] = options.Name + chezmoiTemplateData["targetFile"] = options.Destination } - builder := strings.Builder{} - if err = tmpl.ExecuteTemplate(&builder, name, templateData); err != nil { - return nil, err - } - return []byte(builder.String()), nil + return tmpl.Execute(templateData) } // ForEach calls f for each source state entry. func (s *SourceState) ForEach(f func(RelPath, SourceStateEntry) error) error { - return s.root.ForEach(EmptyRelPath, func(targetRelPath RelPath, entry SourceStateEntry) error { + return s.root.forEach(EmptyRelPath, func(targetRelPath RelPath, entry SourceStateEntry) error { return f(targetRelPath, entry) }) } +// Get returns the source state entry for targetRelPath. +func (s *SourceState) Get(targetRelPath RelPath) SourceStateEntry { + return s.root.get(targetRelPath) +} + // Ignore returns if targetRelPath should be ignored. func (s *SourceState) Ignore(targetRelPath RelPath) bool { - return s.ignore.match(targetRelPath.String()) + s.Lock() + defer s.Unlock() + ignore := s.ignore.match(targetRelPath.String()) == patternSetMatchInclude + if ignore { + s.ignoredRelPaths.Add(targetRelPath) + } + return ignore } -// MinVersion returns the minimum version for which s is valid. -func (s *SourceState) MinVersion() semver.Version { - return s.minVersion +// Ignored returns all ignored RelPaths. +func (s *SourceState) Ignored() RelPaths { + relPaths := RelPaths(s.ignoredRelPaths.Elements()) + sort.Sort(relPaths) + return relPaths } // MustEntry returns the source state entry associated with targetRelPath, and // panics if it does not exist. func (s *SourceState) MustEntry(targetRelPath RelPath) SourceStateEntry { - sourceStateEntry := s.root.Get(targetRelPath) + sourceStateEntry := s.root.get(targetRelPath) if sourceStateEntry == nil { panic(fmt.Sprintf("%s: not in source state", targetRelPath)) } return sourceStateEntry } +// PostApply performs all updates required after s.Apply. +func (s *SourceState) PostApply( + targetSystem System, + persistentState PersistentState, + targetDirAbsPath AbsPath, + targetRelPaths RelPaths, +) error { + // Remove empty directories with the remove_ attribute. This assumes that + // targetRelPaths is already sorted and iterates in reverse order so that + // children are removed before their parents. +TARGET: + for i := len(targetRelPaths) - 1; i >= 0; i-- { + targetRelPath := targetRelPaths[i] + if !s.removeDirs.Contains(targetRelPath) { + continue + } + + // Ensure that we are attempting to remove a directory, not any other entry type. + targetAbsPath := targetDirAbsPath.Join(targetRelPath) + switch fileInfo, err := targetSystem.Stat(targetAbsPath); { + case errors.Is(err, fs.ErrNotExist): + continue TARGET + case err != nil: + return err + case !fileInfo.IsDir(): + return fmt.Errorf("%s: not a directory", targetAbsPath) + } + + // Attempt to remove the directory, but ignore any "not exist" or "not + // empty" errors. + switch err := targetSystem.Remove(targetAbsPath); { + case err == nil: + // Do nothing. + case errors.Is(err, fs.ErrExist): + // Do nothing. + case errors.Is(err, fs.ErrNotExist): + // Do nothing. + default: + return err + } + entryState := &EntryState{ + Type: EntryStateTypeRemove, + } + if err := PersistentStateSet(persistentState, EntryStateBucket, targetAbsPath.Bytes(), entryState); err != nil { + return err + } + } + + return nil +} + // ReadOptions are options to SourceState.Read. type ReadOptions struct { - RefreshExternals bool + ReadHTTPResponse func(*http.Response) ([]byte, error) + RefreshExternals RefreshExternals TimeNow func() time.Time } @@ -712,7 +924,13 @@ func (s *SourceState) Read(ctx context.Context, options *ReadOptions) error { } // Read all source entries. + var allSourceStateEntriesMu sync.Mutex allSourceStateEntries := make(map[RelPath][]SourceStateEntry) + addSourceStateEntries := func(relPath RelPath, sourceStateEntries ...SourceStateEntry) { + allSourceStateEntriesMu.Lock() + defer allSourceStateEntriesMu.Unlock() + allSourceStateEntries[relPath] = append(allSourceStateEntries[relPath], sourceStateEntries...) + } walkFunc := func(sourceAbsPath AbsPath, fileInfo fs.FileInfo, err error) error { if err != nil { return err @@ -720,12 +938,7 @@ func (s *SourceState) Read(ctx context.Context, options *ReadOptions) error { if sourceAbsPath == s.sourceDirAbsPath { return nil } - sourceRelPath := SourceRelPath{ - relPath: sourceAbsPath.MustTrimDirPrefix(s.sourceDirAbsPath), - isDir: fileInfo.IsDir(), - } - parentSourceRelPath, sourceName := sourceRelPath.Split() // Follow symlinks in the source directory. if fileInfo.Mode().Type() == fs.ModeSymlink { // Some programs (notably emacs) use invalid symlinks as lockfiles. @@ -734,69 +947,96 @@ func (s *SourceState) Read(ctx context.Context, options *ReadOptions) error { if strings.HasPrefix(fileInfo.Name(), ignorePrefix) && !strings.HasPrefix(fileInfo.Name(), Prefix) { return nil } - fileInfo, err = s.system.Stat(s.sourceDirAbsPath.Join(sourceRelPath.RelPath())) + fileInfo, err = s.system.Stat(sourceAbsPath) if err != nil { return err } } + + sourceRelPath := SourceRelPath{ + relPath: sourceAbsPath.MustTrimDirPrefix(s.sourceDirAbsPath), + isDir: fileInfo.IsDir(), + } + parentSourceRelPath, sourceName := sourceRelPath.Split() + switch { - case strings.HasPrefix(fileInfo.Name(), dataName): + case fileInfo.Name() == dataName: if !s.readTemplateData { return nil } - return s.addTemplateData(sourceAbsPath) - case strings.HasPrefix(fileInfo.Name(), externalName): - return s.addExternal(sourceAbsPath) - case fileInfo.Name() == ignoreName: - return s.addPatterns(s.ignore, sourceAbsPath, parentSourceRelPath) - case fileInfo.Name() == removeName: - removePatterns := newPatternSet() - if err := s.addPatterns(removePatterns, sourceAbsPath, sourceRelPath); err != nil { - return err - } - matches, err := removePatterns.glob(s.system.UnderlyingFS(), ensureSuffix(s.destDirAbsPath.String(), "/")) - if err != nil { + if err := s.addTemplateDataDir(sourceAbsPath, fileInfo); err != nil { return err } - n := 0 - for _, match := range matches { - if !s.Ignore(NewRelPath(match)) { - matches[n] = match - n++ - } + return fs.SkipDir + case isPrefixDotFormat(fileInfo.Name(), dataName): + if !s.readTemplateData { + return nil } - targetParentRelPath := parentSourceRelPath.TargetRelPath(s.encryption.EncryptedSuffix()) - matches = matches[:n] - for _, match := range matches { - targetRelPath := targetParentRelPath.JoinString(match) - sourceStateEntry := &SourceStateRemove{ - targetRelPath: targetRelPath, + return s.addTemplateData(sourceAbsPath) + case fileInfo.Name() == TemplatesDirName: + if s.readTemplates { + if err := s.addTemplatesDir(ctx, sourceAbsPath); err != nil { + return err } - allSourceStateEntries[targetRelPath] = append(allSourceStateEntries[targetRelPath], sourceStateEntry) } + return fs.SkipDir + case s.templateDataOnly: return nil - case fileInfo.Name() == templatesDirName: - if err := s.addTemplatesDir(sourceAbsPath); err != nil { + case isPrefixDotFormat(fileInfo.Name(), externalName) || isPrefixDotFormatDotTmpl(fileInfo.Name(), externalName): + parentAbsPath, _ := sourceAbsPath.Split() + return s.addExternal(sourceAbsPath, parentAbsPath) + case fileInfo.Name() == externalsDirName: + if err := s.addExternalDir(ctx, sourceAbsPath); err != nil { + return err + } + return fs.SkipDir + case fileInfo.Name() == ignoreName || fileInfo.Name() == ignoreName+TemplateSuffix: + return s.addPatterns(s.ignore, sourceAbsPath, parentSourceRelPath) + case fileInfo.Name() == removeName || fileInfo.Name() == removeName+TemplateSuffix: + return s.addPatterns(s.remove, sourceAbsPath, parentSourceRelPath) + case fileInfo.Name() == scriptsDirName: + scriptsDirSourceStateEntries, err := s.readScriptsDir(ctx, sourceAbsPath) + if err != nil { return err } - return vfs.SkipDir - case fileInfo.Name() == versionName: - return s.addVersionFile(sourceAbsPath) + for relPath, scriptSourceStateEntries := range scriptsDirSourceStateEntries { + addSourceStateEntries(relPath, scriptSourceStateEntries...) + } + return fs.SkipDir + case fileInfo.Name() == VersionName: + return s.readVersionFile(sourceAbsPath) case strings.HasPrefix(fileInfo.Name(), Prefix): fallthrough case strings.HasPrefix(fileInfo.Name(), ignorePrefix): if fileInfo.IsDir() { - return vfs.SkipDir + return fs.SkipDir } return nil case fileInfo.IsDir(): da := parseDirAttr(sourceName.String()) targetRelPath := parentSourceRelPath.Dir().TargetRelPath(s.encryption.EncryptedSuffix()).JoinString(da.TargetName) if s.Ignore(targetRelPath) { - return vfs.SkipDir + return fs.SkipDir + } + sourceStateDir := s.newSourceStateDir(sourceAbsPath, sourceRelPath, da) + addSourceStateEntries(targetRelPath, sourceStateDir) + if da.External { + sourceStateEntries, err := s.readExternalDir(sourceAbsPath, sourceRelPath, targetRelPath) + if err != nil { + return err + } + allSourceStateEntriesMu.Lock() + for relPath, entries := range sourceStateEntries { + allSourceStateEntries[relPath] = append(allSourceStateEntries[relPath], entries...) + } + allSourceStateEntriesMu.Unlock() + return fs.SkipDir + } + if sourceStateDir.Attr.Remove { + s.Lock() + s.removeDirs.Add(targetRelPath) + s.Unlock() } - sourceStateEntry := s.newSourceStateDir(sourceRelPath, da) - allSourceStateEntries[targetRelPath] = append(allSourceStateEntries[targetRelPath], sourceStateEntry) return nil case fileInfo.Mode().IsRegular(): fa := parseFileAttr(sourceName.String(), s.encryption.EncryptedSuffix()) @@ -805,8 +1045,8 @@ func (s *SourceState) Read(ctx context.Context, options *ReadOptions) error { return nil } var sourceStateEntry SourceStateEntry - targetRelPath, sourceStateEntry = s.newSourceStateFile(sourceRelPath, fa, targetRelPath) - allSourceStateEntries[targetRelPath] = append(allSourceStateEntries[targetRelPath], sourceStateEntry) + targetRelPath, sourceStateEntry = s.newSourceStateFile(sourceAbsPath, sourceRelPath, fa, targetRelPath) + addSourceStateEntries(targetRelPath, sourceStateEntry) return nil default: return &unsupportedFileTypeError{ @@ -819,6 +1059,10 @@ func (s *SourceState) Read(ctx context.Context, options *ReadOptions) error { return err } + if s.templateDataOnly { + return nil + } + // Read externals. externalRelPaths := make(RelPaths, 0, len(s.externals)) for externalRelPath := range s.externals { @@ -829,24 +1073,25 @@ func (s *SourceState) Read(ctx context.Context, options *ReadOptions) error { if s.Ignore(externalRelPath) { continue } - external := s.externals[externalRelPath] - parentRelPath, _ := externalRelPath.Split() - var parentSourceRelPath SourceRelPath - switch parentSourceStateEntry, err := s.root.MkdirAll(parentRelPath, external.URL, s.umask); { - case err != nil: - return err - case parentSourceStateEntry != nil: - parentSourceRelPath = parentSourceStateEntry.SourceRelPath() - } - externalSourceStateEntries, err := s.readExternal(ctx, externalRelPath, parentSourceRelPath, external, options) - if err != nil { - return err - } - for targetRelPath, sourceStateEntries := range externalSourceStateEntries { - if s.Ignore(targetRelPath) { - continue + for _, external := range s.externals[externalRelPath] { + parentRelPath, _ := externalRelPath.Split() + var parentSourceRelPath SourceRelPath + switch parentSourceStateEntry, err := s.root.mkdirAll(parentRelPath, external, s.umask); { + case err != nil: + return err + case parentSourceStateEntry != nil: + parentSourceRelPath = parentSourceStateEntry.SourceRelPath() + } + externalSourceStateEntries, err := s.readExternal(ctx, externalRelPath, parentSourceRelPath, external, options) + if err != nil { + return err + } + for targetRelPath, sourceStateEntries := range externalSourceStateEntries { + if s.Ignore(targetRelPath) { + continue + } + allSourceStateEntries[targetRelPath] = append(allSourceStateEntries[targetRelPath], sourceStateEntries...) } - allSourceStateEntries[targetRelPath] = append(allSourceStateEntries[targetRelPath], sourceStateEntries...) } } @@ -857,13 +1102,40 @@ func (s *SourceState) Read(ctx context.Context, options *ReadOptions) error { } } + // Generate SourceStateRemoves for existing targets. + matches, err := s.remove.glob(s.system.UnderlyingFS(), ensureSuffix(s.destDirAbsPath.String(), "/")) + if err != nil { + return err + } + for _, match := range matches { + targetRelPath := NewRelPath(match) + if s.Ignore(targetRelPath) { + continue + } + sourceStateEntry := &SourceStateRemove{ + origin: SourceStateOriginRemove{}, + sourceRelPath: NewSourceRelPath(".chezmoiremove"), + targetRelPath: targetRelPath, + } + allSourceStateEntries[targetRelPath] = append(allSourceStateEntries[targetRelPath], sourceStateEntry) + } + + // Where there are multiple SourceStateEntries for a single target, replace + // them with a single canonical SourceStateEntry if possible. + for targetRelPath, sourceStateEntries := range allSourceStateEntries { + if sourceStateEntry, ok := canonicalSourceStateEntry(sourceStateEntries); ok { + allSourceStateEntries[targetRelPath] = []SourceStateEntry{sourceStateEntry} + } + } + // Generate SourceStateRemoves for exact directories. for targetRelPath, sourceStateEntries := range allSourceStateEntries { if len(sourceStateEntries) != 1 { continue } - switch sourceStateDir, ok := sourceStateEntries[0].(*SourceStateDir); { + sourceStateDir, ok := sourceStateEntries[0].(*SourceStateDir) + switch { case !ok: continue case !sourceStateDir.Attr.Exact: @@ -884,17 +1156,87 @@ func (s *SourceState) Read(ctx context.Context, options *ReadOptions) error { if s.Ignore(destEntryRelPath) { continue } - allSourceStateEntries[destEntryRelPath] = append(allSourceStateEntries[destEntryRelPath], &SourceStateRemove{ + sourceStateRemove := &SourceStateRemove{ + origin: sourceStateDir.Origin(), + sourceRelPath: sourceStateDir.sourceRelPath, targetRelPath: destEntryRelPath, - }) + } + allSourceStateEntries[destEntryRelPath] = append(allSourceStateEntries[destEntryRelPath], sourceStateRemove) } case errors.Is(err, fs.ErrNotExist): // Do nothing. + case errors.Is(err, syscall.ENOTDIR): + // Do nothing. default: return err } } + // Generate SourceStateCommands for git-repo externals. + var gitRepoExternalRelPaths RelPaths + for externalRelPath, externals := range s.externals { + if s.Ignore(externalRelPath) { + continue + } + for _, external := range externals { + if external.Type == ExternalTypeGitRepo { + gitRepoExternalRelPaths = append(gitRepoExternalRelPaths, externalRelPath) + } + } + } + sort.Sort(gitRepoExternalRelPaths) + for _, externalRelPath := range gitRepoExternalRelPaths { + for _, external := range s.externals[externalRelPath] { + destAbsPath := s.destDirAbsPath.Join(externalRelPath) + switch _, err := s.system.Lstat(destAbsPath); { + case errors.Is(err, fs.ErrNotExist): + // FIXME add support for using builtin git + sourceStateCommand := &SourceStateCommand{ + cmd: newLazyCommandFunc(func() *exec.Cmd { + args := []string{"clone"} + args = append(args, external.Clone.Args...) + args = append(args, external.URL, destAbsPath.String()) + cmd := exec.Command("git", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd + }), + origin: external, + forceRefresh: options.RefreshExternals == RefreshExternalsAlways, + refreshPeriod: external.RefreshPeriod, + sourceAttr: SourceAttr{ + External: true, + }, + } + allSourceStateEntries[externalRelPath] = append(allSourceStateEntries[externalRelPath], sourceStateCommand) + case err != nil: + return err + default: + // FIXME add support for using builtin git + sourceStateCommand := &SourceStateCommand{ + cmd: newLazyCommandFunc(func() *exec.Cmd { + args := []string{"pull"} + args = append(args, external.Pull.Args...) + cmd := exec.Command("git", args...) + cmd.Dir = destAbsPath.String() + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd + }), + origin: external, + forceRefresh: options.RefreshExternals == RefreshExternalsAlways, + refreshPeriod: external.RefreshPeriod, + sourceAttr: SourceAttr{ + External: true, + }, + } + allSourceStateEntries[externalRelPath] = append(allSourceStateEntries[externalRelPath], sourceStateCommand) + } + } + } + // Check for inconsistent source entries. Iterate over the target names in // order so that any error is deterministic. targetRelPaths := make(RelPaths, 0, len(allSourceStateEntries)) @@ -902,35 +1244,30 @@ func (s *SourceState) Read(ctx context.Context, options *ReadOptions) error { targetRelPaths = append(targetRelPaths, targetRelPath) } sort.Sort(targetRelPaths) - var err error + errs := make([]error, 0, len(targetRelPaths)) for _, targetRelPath := range targetRelPaths { sourceStateEntries := allSourceStateEntries[targetRelPath] if len(sourceStateEntries) == 1 { continue } - // Allow duplicate equivalent source entries for directories. - if allEquivalentDirs(sourceStateEntries) { - continue - } - origins := make([]string, 0, len(sourceStateEntries)) for _, sourceStateEntry := range sourceStateEntries { - origins = append(origins, sourceStateEntry.Origin()) + origins = append(origins, sourceStateEntry.Origin().OriginString()) } sort.Strings(origins) - err = multierr.Append(err, &inconsistentStateError{ + errs = append(errs, &inconsistentStateError{ targetRelPath: targetRelPath, origins: origins, }) } - if err != nil { - return err + if len(errs) != 0 { + return errors.Join(errs...) } // Populate s.Entries with the unique source entry for each target. for targetRelPath, sourceEntries := range allSourceStateEntries { - s.root.Set(targetRelPath, sourceEntries[0]) + s.root.set(targetRelPath, sourceEntries[0]) } return nil @@ -938,7 +1275,7 @@ func (s *SourceState) Read(ctx context.Context, options *ReadOptions) error { // TargetRelPaths returns all of s's target relative paths in order. func (s *SourceState) TargetRelPaths() []RelPath { - entries := s.root.Map() + entries := s.root.getMap() targetRelPaths := make([]RelPath, 0, len(entries)) for targetRelPath := range entries { targetRelPaths = append(targetRelPaths, targetRelPath) @@ -958,24 +1295,30 @@ func (s *SourceState) TargetRelPaths() []RelPath { return targetRelPaths } -// TemplateData returns s's template data. -func (s *SourceState) TemplateData() map[string]interface{} { +// TemplateData returns a copy of s's template data. +func (s *SourceState) TemplateData() map[string]any { + s.Lock() + defer s.Unlock() + if s.templateData == nil { - s.templateData = make(map[string]interface{}) + s.templateData = make(map[string]any) if s.defaultTemplateDataFunc != nil { - RecursiveMerge(s.templateData, s.defaultTemplateDataFunc()) + s.defaultTemplateData = s.defaultTemplateDataFunc() s.defaultTemplateDataFunc = nil } + RecursiveMerge(s.templateData, s.defaultTemplateData) RecursiveMerge(s.templateData, s.userTemplateData) RecursiveMerge(s.templateData, s.priorityTemplateData) } - return s.templateData + templateData, err := copystructure.Copy(s.templateData) + if err != nil { + panic(err) + } + return templateData.(map[string]any) //nolint:forcetypeassert } // addExternal adds external source entries to s. -func (s *SourceState) addExternal(sourceAbsPath AbsPath) error { - parentAbsPath, _ := sourceAbsPath.Split() - +func (s *SourceState) addExternal(sourceAbsPath, parentAbsPath AbsPath) error { parentRelPath, err := parentAbsPath.TrimDirPrefix(s.sourceDirAbsPath) if err != nil { return err @@ -983,9 +1326,9 @@ func (s *SourceState) addExternal(sourceAbsPath AbsPath) error { parentSourceRelPath := NewSourceRelDirPath(parentRelPath.String()) parentTargetSourceRelPath := parentSourceRelPath.TargetRelPath(s.encryption.EncryptedSuffix()) - format, ok := Formats[strings.TrimPrefix(sourceAbsPath.Ext(), ".")] - if !ok { - return fmt.Errorf("%s: unknown format", sourceAbsPath) + format, err := FormatFromAbsPath(sourceAbsPath.TrimSuffix(TemplateSuffix)) + if err != nil { + return err } data, err := s.executeTemplate(sourceAbsPath) if err != nil { @@ -995,42 +1338,82 @@ func (s *SourceState) addExternal(sourceAbsPath AbsPath) error { if err := format.Unmarshal(data, &externals); err != nil { return fmt.Errorf("%s: %w", sourceAbsPath, err) } - for relPathStr, external := range externals { - targetRelPath := parentTargetSourceRelPath.JoinString(relPathStr) - if _, ok := s.externals[targetRelPath]; ok { - return fmt.Errorf("%s: duplicate externals", targetRelPath) + s.Lock() + defer s.Unlock() + for path, external := range externals { + external := external + if strings.HasPrefix(path, "/") || filepath.IsAbs(path) { + return fmt.Errorf("%s: %s: path is not relative", sourceAbsPath, path) } - s.externals[targetRelPath] = external + targetRelPath := parentTargetSourceRelPath.JoinString(path) + external.sourceAbsPath = sourceAbsPath + s.externals[targetRelPath] = append(s.externals[targetRelPath], &external) } return nil } -// 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)) - lineNumber := 0 - for scanner.Scan() { - lineNumber++ - text := scanner.Text() - if index := strings.IndexRune(text, '#'); index != -1 { - text = text[:index] - } - text = strings.TrimSpace(text) - if text == "" { - continue +// addExternalDir adds all externals in externalsDirAbsPath to s. +func (s *SourceState) addExternalDir(ctx context.Context, externalsDirAbsPath AbsPath) error { + walkFunc := func(ctx context.Context, externalAbsPath AbsPath, fileInfo fs.FileInfo, err error) error { + if externalAbsPath == externalsDirAbsPath { + return nil } - include := true - if strings.HasPrefix(text, "!") { - include = false - text = mustTrimPrefix(text, "!") + if err == nil && fileInfo.Mode().Type() == fs.ModeSymlink { + fileInfo, err = s.system.Stat(externalAbsPath) } - pattern := dir.JoinString(text).String() + switch { + case err != nil: + return err + case strings.HasPrefix(fileInfo.Name(), Prefix): + return fmt.Errorf("%s: not allowed in %s directory", externalAbsPath, externalsDirName) + case strings.HasPrefix(fileInfo.Name(), ignorePrefix): + if fileInfo.IsDir() { + return fs.SkipDir + } + return nil + case fileInfo.Mode().IsRegular(): + parentAbsPath, _ := externalAbsPath.Split() + return s.addExternal(externalAbsPath, parentAbsPath.TrimSuffix("/").Dir()) + case fileInfo.IsDir(): + return nil + default: + return &unsupportedFileTypeError{ + absPath: externalAbsPath, + mode: fileInfo.Mode(), + } + } + } + return concurrentWalkSourceDir(ctx, s.system, externalsDirAbsPath, walkFunc) +} + +// 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 + } + + s.Lock() + defer s.Unlock() + + dir := sourceRelPath.Dir().TargetRelPath("") + scanner := bufio.NewScanner(bytes.NewReader(data)) + lineNumber := 0 + for scanner.Scan() { + lineNumber++ + text := scanner.Text() + text, _, _ = strings.Cut(text, "#") + text = strings.TrimSpace(text) + if text == "" { + continue + } + include := patternSetInclude + text, ok := strings.CutPrefix(text, "!") + if ok { + include = patternSetExclude + } + pattern := dir.JoinString(text).String() if err := patternSet.add(pattern, include); err != nil { return fmt.Errorf("%s:%d: %w", sourceAbsPath, lineNumber, err) } @@ -1043,28 +1426,79 @@ func (s *SourceState) addPatterns(patternSet *patternSet, sourceAbsPath AbsPath, // addTemplateData adds all template data in sourceAbsPath to s. func (s *SourceState) addTemplateData(sourceAbsPath AbsPath) error { - format, ok := Formats[strings.TrimPrefix(sourceAbsPath.Ext(), ".")] - if !ok { - return fmt.Errorf("%s: unknown format", sourceAbsPath) + format, err := FormatFromAbsPath(sourceAbsPath) + if err != nil { + return err } data, err := s.system.ReadFile(sourceAbsPath) if err != nil { return fmt.Errorf("%s: %w", sourceAbsPath, err) } - var templateData map[string]interface{} + var templateData map[string]any if err := format.Unmarshal(data, &templateData); err != nil { return fmt.Errorf("%s: %w", sourceAbsPath, err) } + s.Lock() RecursiveMerge(s.userTemplateData, templateData) + // Clear the cached template data, as the change to the user template data + // means that the cached value is now invalid. + s.templateData = nil + s.Unlock() return nil } -// addTemplatesDir adds all templates in templateDir to s. -func (s *SourceState) addTemplatesDir(templatesDirAbsPath AbsPath) error { - walkFunc := func(templateAbsPath AbsPath, fileInfo fs.FileInfo, err error) error { +// addTemplateData adds all template data in the directory sourceAbsPath to s. +func (s *SourceState) addTemplateDataDir(sourceAbsPath AbsPath, fileInfo fs.FileInfo) error { + walkFunc := func(dataAbsPath AbsPath, fileInfo fs.FileInfo, err error) error { + if dataAbsPath == sourceAbsPath { + return nil + } + if err == nil && fileInfo.Mode().Type() == fs.ModeSymlink { + fileInfo, err = s.system.Stat(dataAbsPath) + } + switch { + case err != nil: + return err + case strings.HasPrefix(fileInfo.Name(), Prefix): + return fmt.Errorf("%s: not allowed in %s directory", dataAbsPath, dataName) + case strings.HasPrefix(fileInfo.Name(), ignorePrefix): + if fileInfo.IsDir() { + return fs.SkipDir + } + return nil + case fileInfo.Mode().IsRegular(): + return s.addTemplateData(dataAbsPath) + case fileInfo.IsDir(): + return nil + default: + return &unsupportedFileTypeError{ + absPath: dataAbsPath, + mode: fileInfo.Mode(), + } + } + } + return walkSourceDir(s.system, sourceAbsPath, fileInfo, walkFunc) +} + +// addTemplatesDir adds all templates in templatesDirAbsPath to s. +func (s *SourceState) addTemplatesDir(ctx context.Context, templatesDirAbsPath AbsPath) error { + walkFunc := func(ctx context.Context, templateAbsPath AbsPath, fileInfo fs.FileInfo, err error) error { + if templateAbsPath == templatesDirAbsPath { + return nil + } + if err == nil && fileInfo.Mode().Type() == fs.ModeSymlink { + fileInfo, err = s.system.Stat(templateAbsPath) + } switch { case err != nil: return err + case strings.HasPrefix(fileInfo.Name(), Prefix): + return fmt.Errorf("%s: not allowed in %s directory", templateAbsPath, TemplatesDirName) + case strings.HasPrefix(fileInfo.Name(), ignorePrefix): + if fileInfo.IsDir() { + return fs.SkipDir + } + return nil case fileInfo.Mode().IsRegular(): contents, err := s.system.ReadFile(templateAbsPath) if err != nil { @@ -1072,14 +1506,16 @@ func (s *SourceState) addTemplatesDir(templatesDirAbsPath AbsPath) error { } templateRelPath := templateAbsPath.MustTrimDirPrefix(templatesDirAbsPath) name := templateRelPath.String() - tmpl, err := template.New(name).Option(s.templateOptions...).Funcs(s.templateFuncs).Parse(string(contents)) + + tmpl, err := ParseTemplate(name, contents, s.templateFuncs, TemplateOptions{ + Options: slices.Clone(s.templateOptions), + }) if err != nil { return err } - if s.templates == nil { - s.templates = make(map[string]*template.Template) - } + s.Lock() s.templates[name] = tmpl + s.Unlock() return nil case fileInfo.IsDir(): return nil @@ -1090,25 +1526,7 @@ func (s *SourceState) addTemplatesDir(templatesDirAbsPath AbsPath) error { } } } - return WalkSourceDir(s.system, templatesDirAbsPath, walkFunc) -} - -// 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 + return concurrentWalkSourceDir(ctx, s.system, templatesDirAbsPath, walkFunc) } // executeTemplate executes the template at path and returns the result. @@ -1117,13 +1535,19 @@ func (s *SourceState) executeTemplate(templateAbsPath AbsPath) ([]byte, error) { if err != nil { return nil, err } - return s.ExecuteTemplateData(templateAbsPath.String(), data) + return s.ExecuteTemplateData(ExecuteTemplateDataOptions{ + Name: templateAbsPath.String(), + Data: data, + }) } // getExternalDataRaw returns the raw data for external at externalRelPath, // possibly from the external cache. func (s *SourceState) getExternalDataRaw( - ctx context.Context, externalRelPath RelPath, external External, options *ReadOptions, + ctx context.Context, + externalRelPath RelPath, + external *External, + options *ReadOptions, ) ([]byte, error) { var now time.Time if options != nil && options.TimeNow != nil { @@ -1133,43 +1557,46 @@ func (s *SourceState) getExternalDataRaw( } now = now.UTC() + refreshExternals := RefreshExternalsAuto + if options != nil { + refreshExternals = options.RefreshExternals + } cacheKey := hex.EncodeToString(SHA256Sum([]byte(external.URL))) - cachedDataAbsPath := s.cacheDirAbsPath.JoinString("external", cacheKey+"."+externalCacheFormat.Name()) - if options == nil || !options.RefreshExternals { - if data, err := s.system.ReadFile(cachedDataAbsPath); err == nil { - var externalCacheEntry externalCacheEntry - if err := externalCacheFormat.Unmarshal(data, &externalCacheEntry); err == nil { - if externalCacheEntry.URL == external.URL { - if external.RefreshPeriod == 0 || externalCacheEntry.Time.Add(external.RefreshPeriod).After(now) { - return externalCacheEntry.Data, nil - } + cachedDataAbsPath := s.cacheDirAbsPath.JoinString("external", cacheKey) + switch refreshExternals { + case RefreshExternalsAlways: + // Never use the cache. + case RefreshExternalsAuto: + // Use the cache, if available and within the refresh period. + if fileInfo, err := s.baseSystem.Stat(cachedDataAbsPath); err == nil { + if external.RefreshPeriod == 0 || fileInfo.ModTime().Add(time.Duration(external.RefreshPeriod)).After(now) { + if data, err := s.baseSystem.ReadFile(cachedDataAbsPath); err == nil { + return data, nil } } } + case RefreshExternalsNever: + // Always use the cache, if available, irrespective of the refresh + // period. + if data, err := s.baseSystem.ReadFile(cachedDataAbsPath); err == nil { + return data, nil + } } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, external.URL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, external.URL, http.NoBody) if err != nil { return nil, err } - resp, err := s.httpClient.Do(req) - if resp != nil { - s.logger.Err(err). - Str("method", req.Method). - Int("statusCode", resp.StatusCode). - Str("status", resp.Status). - Stringer("url", req.URL). - Msg("HTTP") - } else { - s.logger.Err(err). - Str("method", req.Method). - Stringer("url", req.URL). - Msg("HTTP") - } + resp, err := chezmoilog.LogHTTPRequest(ctx, s.logger, s.httpClient, req) if err != nil { return nil, err } - data, err := io.ReadAll(resp.Body) + var data []byte + if options == nil || options.ReadHTTPResponse == nil { + data, err = io.ReadAll(resp.Body) + } else { + data, err = options.ReadHTTPResponse(resp) + } resp.Body.Close() if err != nil { return nil, err @@ -1178,34 +1605,89 @@ func (s *SourceState) getExternalDataRaw( return nil, fmt.Errorf("%s: %s: %s", externalRelPath, external.URL, resp.Status) } - cachedExternalData, err := externalCacheFormat.Marshal(&externalCacheEntry{ - URL: external.URL, - Time: now, - Data: data, - }) - if err != nil { + if err := MkdirAll(s.baseSystem, cachedDataAbsPath.Dir(), 0o700); err != nil { return nil, err } - if err := MkdirAll(s.baseSystem, cachedDataAbsPath.Dir(), 0o700); err != nil { + if err := s.baseSystem.WriteFile(cachedDataAbsPath, data, 0o600); err != nil { return nil, err } - if err := s.baseSystem.WriteFile(cachedDataAbsPath, cachedExternalData, 0o600); err != nil { + if err := s.baseSystem.Chtimes(cachedDataAbsPath, now, now); err != nil { return nil, err } return data, nil } -// getExternalDataRaw reads the external data for externalRelPath from +// getExternalData reads the external data for externalRelPath from // external.URL. func (s *SourceState) getExternalData( - ctx context.Context, externalRelPath RelPath, external External, options *ReadOptions, + ctx context.Context, + externalRelPath RelPath, + external *External, + options *ReadOptions, ) ([]byte, error) { data, err := s.getExternalDataRaw(ctx, externalRelPath, external, options) if err != nil { return nil, err } + var errs []error + + if external.Checksum.Size != 0 { + if len(data) != external.Checksum.Size { + err := fmt.Errorf("size mismatch: expected %d, got %d", external.Checksum.Size, len(data)) + errs = append(errs, err) + } + } + + if external.Checksum.MD5 != nil { + if gotMD5Sum := md5Sum(data); !bytes.Equal(gotMD5Sum, external.Checksum.MD5) { + err := fmt.Errorf("MD5 mismatch: expected %s, got %s", external.Checksum.MD5, hex.EncodeToString(gotMD5Sum)) + errs = append(errs, err) + } + } + + if external.Checksum.RIPEMD160 != nil { + if gotRIPEMD160Sum := ripemd160Sum(data); !bytes.Equal(gotRIPEMD160Sum, external.Checksum.RIPEMD160) { + format := "RIPEMD-160 mismatch: expected %s, got %s" + err := fmt.Errorf(format, external.Checksum.RIPEMD160, hex.EncodeToString(gotRIPEMD160Sum)) + errs = append(errs, err) + } + } + + if external.Checksum.SHA1 != nil { + if gotSHA1Sum := sha1Sum(data); !bytes.Equal(gotSHA1Sum, external.Checksum.SHA1) { + err := fmt.Errorf("SHA1 mismatch: expected %s, got %s", external.Checksum.SHA1, hex.EncodeToString(gotSHA1Sum)) + errs = append(errs, err) + } + } + + if external.Checksum.SHA256 != nil { + if gotSHA256Sum := SHA256Sum(data); !bytes.Equal(gotSHA256Sum, external.Checksum.SHA256) { + format := "SHA256 mismatch: expected %s, got %s" + err := fmt.Errorf(format, external.Checksum.SHA256, hex.EncodeToString(gotSHA256Sum)) + errs = append(errs, err) + } + } + + if external.Checksum.SHA384 != nil { + if gotSHA384Sum := sha384Sum(data); !bytes.Equal(gotSHA384Sum, external.Checksum.SHA384) { + errs = append(errs, fmt.Errorf("SHA384 mismatch: expected %s, got %s", + external.Checksum.SHA384, hex.EncodeToString(gotSHA384Sum))) + } + } + + if external.Checksum.SHA512 != nil { + if gotSHA512Sum := sha512Sum(data); !bytes.Equal(gotSHA512Sum, external.Checksum.SHA512) { + errs = append(errs, fmt.Errorf("SHA512 mismatch: expected %s, got %s", + external.Checksum.SHA512, hex.EncodeToString(gotSHA512Sum))) + } + } + + if len(errs) != 0 { + return nil, fmt.Errorf("%s: %w", externalRelPath, errors.Join(errs...)) + } + if external.Encrypted { data, err = s.encryption.Decrypt(data) if err != nil { @@ -1213,11 +1695,16 @@ func (s *SourceState) getExternalData( } } + data, err = decompress(external.Decompress, data) + if err != nil { + return nil, fmt.Errorf("%s: %w", externalRelPath, err) + } + if external.Filter.Command != "" { - //nolint:gosec - cmd := exec.Command(external.Filter.Command, external.Filter.Args...) + cmd := exec.Command(external.Filter.Command, external.Filter.Args...) //nolint:gosec cmd.Stdin = bytes.NewReader(data) - data, err = s.system.IdempotentCmdOutput(cmd) + cmd.Stderr = os.Stderr + data, err = chezmoilog.LogCmdOutput(s.logger, cmd) if err != nil { return nil, fmt.Errorf("%s: %s: %w", externalRelPath, external.URL, err) } @@ -1227,12 +1714,12 @@ func (s *SourceState) getExternalData( } // newSourceStateDir returns a new SourceStateDir. -func (s *SourceState) newSourceStateDir(sourceRelPath SourceRelPath, dirAttr DirAttr) *SourceStateDir { +func (s *SourceState) newSourceStateDir(absPath AbsPath, sourceRelPath SourceRelPath, dirAttr DirAttr) *SourceStateDir { targetStateDir := &TargetStateDir{ perm: dirAttr.perm() &^ s.umask, } return &SourceStateDir{ - origin: sourceRelPath.String(), + origin: SourceStateOriginAbsPath(absPath), sourceRelPath: sourceRelPath, Attr: dirAttr, targetStateEntry: targetStateDir, @@ -1243,7 +1730,9 @@ func (s *SourceState) newSourceStateDir(sourceRelPath SourceRelPath, dirAttr Dir // file with sourceLazyContents if the file does not already exist, or returns // the actual file's contents unchanged if the file already exists. func (s *SourceState) newCreateTargetStateEntryFunc( - sourceRelPath SourceRelPath, fileAttr FileAttr, sourceLazyContents *lazyContents, + sourceRelPath SourceRelPath, + fileAttr FileAttr, + sourceLazyContents *lazyContents, ) targetStateEntryFunc { return func(destSystem System, destAbsPath AbsPath) (TargetStateEntry, error) { var lazyContents *lazyContents @@ -1257,7 +1746,11 @@ func (s *SourceState) newCreateTargetStateEntryFunc( return nil, err } if fileAttr.Template { - contents, err = s.ExecuteTemplateData(sourceRelPath.String(), contents) + contents, err = s.ExecuteTemplateData(ExecuteTemplateDataOptions{ + Name: sourceRelPath.String(), + Data: contents, + Destination: destAbsPath.String(), + }) if err != nil { return nil, err } @@ -1269,8 +1762,12 @@ func (s *SourceState) newCreateTargetStateEntryFunc( } return &TargetStateFile{ lazyContents: lazyContents, - empty: true, + empty: fileAttr.Empty, perm: fileAttr.perm() &^ s.umask, + sourceAttr: SourceAttr{ + Encrypted: fileAttr.Encrypted, + Template: fileAttr.Template, + }, }, nil } } @@ -1278,7 +1775,9 @@ func (s *SourceState) newCreateTargetStateEntryFunc( // newFileTargetStateEntryFunc returns a targetStateEntryFunc that returns a // file with sourceLazyContents. func (s *SourceState) newFileTargetStateEntryFunc( - sourceRelPath SourceRelPath, fileAttr FileAttr, sourceLazyContents *lazyContents, + sourceRelPath SourceRelPath, + fileAttr FileAttr, + sourceLazyContents *lazyContents, ) targetStateEntryFunc { return func(destSystem System, destAbsPath AbsPath) (TargetStateEntry, error) { if s.mode == ModeSymlink && !fileAttr.Encrypted && !fileAttr.Executable && !fileAttr.Private && !fileAttr.Template { @@ -1291,6 +1790,9 @@ func (s *SourceState) newFileTargetStateEntryFunc( linkname := normalizeLinkname(s.sourceDirAbsPath.Join(sourceRelPath.RelPath()).String()) return &TargetStateSymlink{ lazyLinkname: newLazyLinkname(linkname), + sourceAttr: SourceAttr{ + Template: fileAttr.Template, + }, }, nil } } @@ -1300,7 +1802,11 @@ func (s *SourceState) newFileTargetStateEntryFunc( return nil, err } if fileAttr.Template { - contents, err = s.ExecuteTemplateData(sourceRelPath.String(), contents) + contents, err = s.ExecuteTemplateData(ExecuteTemplateDataOptions{ + Name: sourceRelPath.String(), + Data: contents, + Destination: destAbsPath.String(), + }) if err != nil { return nil, err } @@ -1311,6 +1817,10 @@ func (s *SourceState) newFileTargetStateEntryFunc( lazyContents: newLazyContentsFunc(contentsFunc), empty: fileAttr.Empty, perm: fileAttr.perm() &^ s.umask, + sourceAttr: SourceAttr{ + Encrypted: fileAttr.Encrypted, + Template: fileAttr.Template, + }, }, nil } } @@ -1318,10 +1828,15 @@ func (s *SourceState) newFileTargetStateEntryFunc( // newModifyTargetStateEntryFunc returns a targetStateEntryFunc that returns a // file with the contents modified by running the sourceLazyContents script. func (s *SourceState) newModifyTargetStateEntryFunc( - sourceRelPath SourceRelPath, fileAttr FileAttr, sourceLazyContents *lazyContents, interpreter *Interpreter, + sourceRelPath SourceRelPath, + fileAttr FileAttr, + sourceLazyContents *lazyContents, + interpreter *Interpreter, ) targetStateEntryFunc { return func(destSystem System, destAbsPath AbsPath) (TargetStateEntry, error) { contentsFunc := func() (contents []byte, err error) { + // FIXME this should share code with RealSystem.RunScript + // Read the current contents of the target. var currentContents []byte currentContents, err = destSystem.ReadFile(destAbsPath) @@ -1336,7 +1851,11 @@ func (s *SourceState) newModifyTargetStateEntryFunc( return } if fileAttr.Template { - modifierContents, err = s.ExecuteTemplateData(sourceRelPath.String(), modifierContents) + modifierContents, err = s.ExecuteTemplateData(ExecuteTemplateDataOptions{ + Name: sourceRelPath.String(), + Data: modifierContents, + Destination: destAbsPath.String(), + }) if err != nil { return } @@ -1348,30 +1867,72 @@ func (s *SourceState) newModifyTargetStateEntryFunc( return } + // If the modifier contains chezmoi:modify-template then execute it + // as a template. + if matches := modifyTemplateRx.FindAllSubmatchIndex(modifierContents, -1); matches != nil { + sourceFile := sourceRelPath.String() + templateContents := removeMatches(modifierContents, matches) + var tmpl *Template + tmpl, err = ParseTemplate( + sourceFile, + templateContents, + s.templateFuncs, + TemplateOptions{ + Options: slices.Clone(s.templateOptions), + }, + ) + if err != nil { + return + } + + // Temporarily set .chezmoi.stdin to the current contents and + // .chezmoi.sourceFile to the name of the template. + templateData := s.TemplateData() + if chezmoiTemplateData, ok := templateData["chezmoi"].(map[string]any); ok { + chezmoiTemplateData["stdin"] = string(currentContents) + chezmoiTemplateData["sourceFile"] = sourceFile + } + + contents, err = tmpl.Execute(templateData) + return + } + + // Create the script temporary directory, if needed. + s.createScriptTempDirOnce.Do(func() { + if !s.scriptTempDirAbsPath.Empty() { + err = os.MkdirAll(s.scriptTempDirAbsPath.String(), 0o700) + } + }) + if err != nil { + return + } + // Write the modifier to a temporary file. var tempFile *os.File - if tempFile, err = os.CreateTemp("", "*."+fileAttr.TargetName); err != nil { + if tempFile, err = os.CreateTemp(s.scriptTempDirAbsPath.String(), "*."+fileAttr.TargetName); err != nil { return } - defer func() { - err = multierr.Append(err, os.RemoveAll(tempFile.Name())) - }() + defer chezmoierrors.CombineFunc(&err, func() error { + return os.RemoveAll(tempFile.Name()) + }) if runtime.GOOS != "windows" { if err = tempFile.Chmod(0o700); err != nil { return } } _, err = tempFile.Write(modifierContents) - err = multierr.Append(err, tempFile.Close()) - if err != nil { + if chezmoierrors.CombineFunc(&err, tempFile.Close); err != nil { return } // Run the modifier on the current contents. cmd := interpreter.ExecCommand(tempFile.Name()) + cmd.Env = append(os.Environ(), + "CHEZMOI_SOURCE_FILE="+sourceRelPath.String(), + ) cmd.Stdin = bytes.NewReader(currentContents) cmd.Stderr = os.Stderr - contents, err = destSystem.IdempotentCmdOutput(cmd) + contents, err = chezmoilog.LogCmdOutput(s.logger, cmd) return } return &TargetStateFile{ @@ -1384,9 +1945,7 @@ func (s *SourceState) newModifyTargetStateEntryFunc( // newRemoveTargetStateEntryFunc returns a targetStateEntryFunc that removes a // target. -func (s *SourceState) newRemoveTargetStateEntryFunc( - sourceRelPath SourceRelPath, fileAttr FileAttr, -) targetStateEntryFunc { +func (s *SourceState) newRemoveTargetStateEntryFunc() targetStateEntryFunc { return func(destSystem System, destAbsPath AbsPath) (TargetStateEntry, error) { return &TargetStateRemove{}, nil } @@ -1395,7 +1954,10 @@ func (s *SourceState) newRemoveTargetStateEntryFunc( // newScriptTargetStateEntryFunc returns a targetStateEntryFunc that returns a // script with sourceLazyContents. func (s *SourceState) newScriptTargetStateEntryFunc( - sourceRelPath SourceRelPath, fileAttr FileAttr, targetRelPath RelPath, sourceLazyContents *lazyContents, + sourceRelPath SourceRelPath, + fileAttr FileAttr, + targetRelPath RelPath, + sourceLazyContents *lazyContents, interpreter *Interpreter, ) targetStateEntryFunc { return func(destSystem System, destAbsPath AbsPath) (TargetStateEntry, error) { @@ -1405,7 +1967,11 @@ func (s *SourceState) newScriptTargetStateEntryFunc( return nil, err } if fileAttr.Template { - contents, err = s.ExecuteTemplateData(sourceRelPath.String(), contents) + contents, err = s.ExecuteTemplateData(ExecuteTemplateDataOptions{ + Name: sourceRelPath.String(), + Data: contents, + Destination: destAbsPath.String(), + }) if err != nil { return nil, err } @@ -1417,6 +1983,10 @@ func (s *SourceState) newScriptTargetStateEntryFunc( name: targetRelPath, condition: fileAttr.Condition, interpreter: interpreter, + sourceAttr: SourceAttr{ + Condition: fileAttr.Condition, + }, + sourceRelPath: sourceRelPath, }, nil } } @@ -1424,7 +1994,9 @@ func (s *SourceState) newScriptTargetStateEntryFunc( // newSymlinkTargetStateEntryFunc returns a targetStateEntryFunc that returns a // symlink with the linkname sourceLazyContents. func (s *SourceState) newSymlinkTargetStateEntryFunc( - sourceRelPath SourceRelPath, fileAttr FileAttr, sourceLazyContents *lazyContents, + sourceRelPath SourceRelPath, + fileAttr FileAttr, + sourceLazyContents *lazyContents, ) targetStateEntryFunc { return func(destSystem System, destAbsPath AbsPath) (TargetStateEntry, error) { linknameFunc := func() (string, error) { @@ -1433,7 +2005,11 @@ func (s *SourceState) newSymlinkTargetStateEntryFunc( return "", err } if fileAttr.Template { - linknameBytes, err = s.ExecuteTemplateData(sourceRelPath.String(), linknameBytes) + linknameBytes, err = s.ExecuteTemplateData(ExecuteTemplateDataOptions{ + Name: sourceRelPath.String(), + Data: linknameBytes, + Destination: destAbsPath.String(), + }) if err != nil { return "", err } @@ -1450,7 +2026,10 @@ func (s *SourceState) newSymlinkTargetStateEntryFunc( // newSourceStateFile returns a possibly new target RalPath and a new // SourceStateFile. func (s *SourceState) newSourceStateFile( - sourceRelPath SourceRelPath, fileAttr FileAttr, targetRelPath RelPath, + absPath AbsPath, + sourceRelPath SourceRelPath, + fileAttr FileAttr, + targetRelPath RelPath, ) (RelPath, *SourceStateFile) { sourceLazyContents := newLazyContentsFunc(func() ([]byte, error) { contents, err := s.system.ReadFile(s.sourceDirAbsPath.Join(sourceRelPath.RelPath())) @@ -1475,24 +2054,32 @@ func (s *SourceState) newSourceStateFile( case SourceFileTypeModify: // If the target has an extension, determine if it indicates an // interpreter to use. - ext := strings.ToLower(strings.TrimPrefix(targetRelPath.Ext(), ".")) - interpreter := s.interpreters[ext] - if interpreter != nil { + extension := strings.ToLower(strings.TrimPrefix(targetRelPath.Ext(), ".")) + if interpreter, ok := s.interpreters[extension]; ok { // For modify scripts, the script extension is not considered part // of the target name, so remove it. - targetRelPath = targetRelPath.Slice(0, targetRelPath.Len()-len(ext)-1) + targetRelPath = targetRelPath.Slice(0, targetRelPath.Len()-len(extension)-1) + targetStateEntryFunc = s.newModifyTargetStateEntryFunc(sourceRelPath, fileAttr, sourceLazyContents, &interpreter) + } else { + targetStateEntryFunc = s.newModifyTargetStateEntryFunc(sourceRelPath, fileAttr, sourceLazyContents, nil) } - targetStateEntryFunc = s.newModifyTargetStateEntryFunc(sourceRelPath, fileAttr, sourceLazyContents, interpreter) case SourceFileTypeRemove: - targetStateEntryFunc = s.newRemoveTargetStateEntryFunc(sourceRelPath, fileAttr) + targetStateEntryFunc = s.newRemoveTargetStateEntryFunc() case SourceFileTypeScript: // If the script has an extension, determine if it indicates an // interpreter to use. - ext := strings.ToLower(strings.TrimPrefix(targetRelPath.Ext(), ".")) - interpreter := s.interpreters[ext] - targetStateEntryFunc = s.newScriptTargetStateEntryFunc( - sourceRelPath, fileAttr, targetRelPath, sourceLazyContents, interpreter, - ) + extension := strings.ToLower(strings.TrimPrefix(targetRelPath.Ext(), ".")) + if interpreter, ok := s.interpreters[extension]; ok { + targetStateEntryFunc = s.newScriptTargetStateEntryFunc( + sourceRelPath, + fileAttr, + targetRelPath, + sourceLazyContents, + &interpreter, + ) + } else { + targetStateEntryFunc = s.newScriptTargetStateEntryFunc(sourceRelPath, fileAttr, targetRelPath, sourceLazyContents, nil) + } case SourceFileTypeSymlink: targetStateEntryFunc = s.newSymlinkTargetStateEntryFunc(sourceRelPath, fileAttr, sourceLazyContents) default: @@ -1501,7 +2088,7 @@ func (s *SourceState) newSourceStateFile( return targetRelPath, &SourceStateFile{ lazyContents: sourceLazyContents, - origin: sourceRelPath.String(), + origin: SourceStateOriginAbsPath(absPath), sourceRelPath: sourceRelPath, Attr: fileAttr, targetStateEntryFunc: targetStateEntryFunc, @@ -1509,11 +2096,12 @@ func (s *SourceState) newSourceStateFile( } // newSourceStateDirEntry returns a SourceStateEntry constructed from a directory in s. -// -// We return a SourceStateEntry rather than a *SourceStateDir to simplify nil checks later. func (s *SourceState) newSourceStateDirEntry( - fileInfo fs.FileInfo, parentSourceRelPath SourceRelPath, options *AddOptions, -) (SourceStateEntry, error) { + actualStateDir *ActualStateDir, + fileInfo fs.FileInfo, + parentSourceRelPath SourceRelPath, + options *AddOptions, +) *SourceStateDir { dirAttr := DirAttr{ TargetName: fileInfo.Name(), Exact: options.Exact, @@ -1523,27 +2111,26 @@ func (s *SourceState) newSourceStateDirEntry( sourceRelPath := parentSourceRelPath.Join(NewSourceRelDirPath(dirAttr.SourceName())) return &SourceStateDir{ Attr: dirAttr, - origin: sourceRelPath.String(), + origin: actualStateDir, sourceRelPath: sourceRelPath, targetStateEntry: &TargetStateDir{ - perm: 0o777 &^ s.umask, + perm: fs.ModePerm &^ s.umask, }, - }, nil + } } // newSourceStateFileEntryFromFile returns a SourceStateEntry constructed from a // file in s. -// -// We return a SourceStateEntry rather than a *SourceStateFile to simplify nil -// checks later. func (s *SourceState) newSourceStateFileEntryFromFile( - actualStateFile *ActualStateFile, fileInfo fs.FileInfo, parentSourceRelPath SourceRelPath, options *AddOptions, -) (SourceStateEntry, error) { + actualStateFile *ActualStateFile, + fileInfo fs.FileInfo, + parentSourceRelPath SourceRelPath, + options *AddOptions, +) (*SourceStateFile, error) { fileAttr := FileAttr{ TargetName: fileInfo.Name(), - Empty: options.Empty, Encrypted: options.Encrypt, - Executable: isExecutable(fileInfo), + Executable: IsExecutable(fileInfo), Private: isPrivate(fileInfo), ReadOnly: isReadOnly(fileInfo), Template: options.Template, @@ -1564,8 +2151,8 @@ func (s *SourceState) newSourceStateFileEntryFromFile( fileAttr.Template = true } } - if len(contents) == 0 && !options.Empty { - return nil, nil + if len(contents) == 0 { + fileAttr.Empty = true } if options.Encrypt { contents, err = s.encryption.Encrypt(contents) @@ -1577,12 +2164,12 @@ func (s *SourceState) newSourceStateFileEntryFromFile( sourceRelPath := parentSourceRelPath.Join(NewSourceRelPath(fileAttr.SourceName(s.encryption.EncryptedSuffix()))) return &SourceStateFile{ Attr: fileAttr, - origin: sourceRelPath.String(), + origin: actualStateFile, sourceRelPath: sourceRelPath, lazyContents: lazyContents, targetStateEntry: &TargetStateFile{ lazyContents: lazyContents, - empty: options.Empty, + empty: len(contents) == 0, perm: 0o666 &^ s.umask, }, }, nil @@ -1590,13 +2177,12 @@ func (s *SourceState) newSourceStateFileEntryFromFile( // newSourceStateFileEntryFromSymlink returns a SourceStateEntry constructed // from a symlink in s. -// -// We return a SourceStateEntry rather than a *SourceStateFile to simplify nil -// checks later. func (s *SourceState) newSourceStateFileEntryFromSymlink( - actualStateSymlink *ActualStateSymlink, fileInfo fs.FileInfo, parentSourceRelPath SourceRelPath, + actualStateSymlink *ActualStateSymlink, + fileInfo fs.FileInfo, + parentSourceRelPath SourceRelPath, options *AddOptions, -) (SourceStateEntry, error) { +) (*SourceStateFile, error) { linkname, err := actualStateSymlink.Linkname() if err != nil { return nil, err @@ -1637,16 +2223,44 @@ func (s *SourceState) newSourceStateFileEntryFromSymlink( }, nil } +// populateImplicitParentDirs creates implicit parent directories for externalRelPath. +func (s *SourceState) populateImplicitParentDirs( + externalRelPath RelPath, + external *External, + sourceStateEntries map[RelPath][]SourceStateEntry, +) map[RelPath][]SourceStateEntry { + for relPath := externalRelPath.Dir(); relPath != DotRelPath; relPath = relPath.Dir() { + sourceStateEntries[relPath] = append(sourceStateEntries[relPath], + &SourceStateImplicitDir{ + origin: external, + targetStateEntry: &TargetStateDir{ + perm: fs.ModePerm &^ s.umask, + }, + }, + ) + } + return sourceStateEntries +} + // readExternal reads an external and returns its SourceStateEntries. func (s *SourceState) readExternal( - ctx context.Context, externalRelPath RelPath, parentSourceRelPath SourceRelPath, external External, + ctx context.Context, + externalRelPath RelPath, + parentSourceRelPath SourceRelPath, + external *External, options *ReadOptions, ) (map[RelPath][]SourceStateEntry, error) { switch external.Type { case ExternalTypeArchive: return s.readExternalArchive(ctx, externalRelPath, parentSourceRelPath, external, options) + case ExternalTypeArchiveFile: + return s.readExternalArchiveFile(ctx, externalRelPath, parentSourceRelPath, external, options) case ExternalTypeFile: return s.readExternalFile(ctx, externalRelPath, parentSourceRelPath, external, options) + case ExternalTypeGitRepo: + return nil, nil + case "": + return nil, fmt.Errorf("%s: missing external type", externalRelPath) default: return nil, fmt.Errorf("%s: unknown external type: %s", externalRelPath, external.Type) } @@ -1655,45 +2269,63 @@ func (s *SourceState) readExternal( // readExternalArchive reads an external archive and returns its // SourceStateEntries. func (s *SourceState) readExternalArchive( - ctx context.Context, externalRelPath RelPath, parentSourceRelPath SourceRelPath, external External, + ctx context.Context, + externalRelPath RelPath, + parentSourceRelPath SourceRelPath, + external *External, options *ReadOptions, ) (map[RelPath][]SourceStateEntry, error) { - data, err := s.getExternalData(ctx, externalRelPath, external, options) + data, format, err := s.readExternalArchiveData(ctx, externalRelPath, external, options) if err != nil { return nil, err } - url, err := url.Parse(external.URL) - if err != nil { - return nil, fmt.Errorf("%s: %s: %w", externalRelPath, external.URL, err) - } - urlPath := url.Path - if external.Encrypted { - urlPath = strings.TrimSuffix(urlPath, s.encryption.EncryptedSuffix()) - } dirAttr := DirAttr{ TargetName: externalRelPath.Base(), Exact: external.Exact, } sourceStateDir := &SourceStateDir{ Attr: dirAttr, - origin: external.URL, + origin: external, sourceRelPath: parentSourceRelPath.Join(NewSourceRelPath(dirAttr.SourceName())), targetStateEntry: &TargetStateDir{ - perm: 0o777 &^ s.umask, + perm: fs.ModePerm &^ s.umask, + sourceAttr: SourceAttr{ + External: true, + }, }, } sourceStateEntries := map[RelPath][]SourceStateEntry{ externalRelPath: {sourceStateDir}, } - format := external.Format - if format == ArchiveFormatUnknown { - format = GuessArchiveFormat(urlPath, data) + patternSet := newPatternSet() + for _, includePattern := range external.Include { + if err := patternSet.add(includePattern, patternSetInclude); err != nil { + return nil, err + } + } + for _, excludePattern := range external.Exclude { + if err := patternSet.add(excludePattern, patternSetExclude); err != nil { + return nil, err + } } sourceRelPaths := make(map[RelPath]SourceRelPath) - if err := walkArchive(data, format, func(name string, fileInfo fs.FileInfo, r io.Reader, linkname string) error { + if err := WalkArchive(data, format, func(name string, fileInfo fs.FileInfo, r io.Reader, linkname string) error { + // Perform matching against the name before stripping any components, + // otherwise it is not possible to differentiate between + // identically-named files at the same level. + if patternSet.match(name) == patternSetMatchExclude { + // In case that `name` is a directory which matched an explicit + // exclude pattern, return fs.SkipDir to exclude not just the + // directory itself but also everything it contains (recursively). + if fileInfo.IsDir() && len(patternSet.excludePatterns) > 0 { + return fs.SkipDir + } + return nil + } + if external.StripComponents > 0 { components := strings.Split(name, "/") if len(components) <= external.StripComponents { @@ -1707,6 +2339,9 @@ func (s *SourceState) readExternalArchive( targetRelPath := externalRelPath.JoinString(name) if s.Ignore(targetRelPath) { + if fileInfo.IsDir() { + return fs.SkipDir + } return nil } @@ -1718,6 +2353,9 @@ func (s *SourceState) readExternalArchive( case fileInfo.IsDir(): targetStateEntry := &TargetStateDir{ perm: fileInfo.Mode().Perm() &^ s.umask, + sourceAttr: SourceAttr{ + External: true, + }, } dirAttr := DirAttr{ TargetName: fileInfo.Name(), @@ -1727,7 +2365,7 @@ func (s *SourceState) readExternalArchive( } sourceStateEntry = &SourceStateDir{ Attr: dirAttr, - origin: external.URL, + origin: external, sourceRelPath: parentSourceRelPath.Join(dirSourceRelPath, NewSourceRelPath(dirAttr.SourceName())), targetStateEntry: targetStateEntry, } @@ -1736,12 +2374,17 @@ func (s *SourceState) readExternalArchive( if err != nil { return fmt.Errorf("%s: %w", name, err) } + + if !external.Archive.ExtractAppleDoubleFiles && isAppleDoubleFile(name, contents) { + return nil + } + lazyContents := newLazyContents(contents) fileAttr := FileAttr{ TargetName: fileInfo.Name(), Type: SourceFileTypeFile, Empty: fileInfo.Size() == 0, - Executable: isExecutable(fileInfo), + Executable: IsExecutable(fileInfo), Private: isPrivate(fileInfo), ReadOnly: isReadOnly(fileInfo), } @@ -1750,11 +2393,14 @@ func (s *SourceState) readExternalArchive( lazyContents: lazyContents, empty: fileAttr.Empty, perm: fileAttr.perm() &^ s.umask, + sourceAttr: SourceAttr{ + External: true, + }, } sourceStateEntry = &SourceStateFile{ lazyContents: lazyContents, Attr: fileAttr, - origin: external.URL, + origin: external, sourceRelPath: parentSourceRelPath.Join(dirSourceRelPath, sourceRelPath), targetStateEntry: targetStateEntry, } @@ -1766,10 +2412,13 @@ func (s *SourceState) readExternalArchive( sourceRelPath := NewSourceRelPath(fileAttr.SourceName(s.encryption.EncryptedSuffix())) targetStateEntry := &TargetStateSymlink{ lazyLinkname: newLazyLinkname(linkname), + sourceAttr: SourceAttr{ + External: true, + }, } sourceStateEntry = &SourceStateFile{ Attr: fileAttr, - origin: external.URL, + origin: external, sourceRelPath: parentSourceRelPath.Join(dirSourceRelPath, sourceRelPath), targetStateEntry: targetStateEntry, } @@ -1782,12 +2431,248 @@ func (s *SourceState) readExternalArchive( return nil, fmt.Errorf("%s: %s: %w", externalRelPath, external.URL, err) } + return s.populateImplicitParentDirs(externalRelPath, external, sourceStateEntries), nil +} + +// readExternalArchiveData reads an external archive's data and returns its data +// and format. +func (s *SourceState) readExternalArchiveData( + ctx context.Context, + externalRelPath RelPath, + external *External, + options *ReadOptions, +) ([]byte, ArchiveFormat, error) { + data, err := s.getExternalData(ctx, externalRelPath, external, options) + if err != nil { + return nil, ArchiveFormatUnknown, err + } + + url, err := url.Parse(external.URL) + if err != nil { + err := fmt.Errorf("%s: %s: %w", externalRelPath, external.URL, err) + return nil, ArchiveFormatUnknown, err + } + urlPath := url.Path + if external.Encrypted { + urlPath = strings.TrimSuffix(urlPath, s.encryption.EncryptedSuffix()) + } + + format := external.Format + if format == ArchiveFormatUnknown { + format = GuessArchiveFormat(urlPath, data) + } + + return data, format, nil +} + +// readExternalArchiveFile reads a file from an external archive and returns its +// SourceStateEntries. +func (s *SourceState) readExternalArchiveFile( + ctx context.Context, + externalRelPath RelPath, + parentSourceRelPath SourceRelPath, + external *External, + options *ReadOptions, +) (map[RelPath][]SourceStateEntry, error) { + if external.ArchivePath == "" { + return nil, fmt.Errorf("%s: missing path", externalRelPath) + } + + data, format, err := s.readExternalArchiveData(ctx, externalRelPath, external, options) + if err != nil { + return nil, err + } + + var sourceStateEntry SourceStateEntry + if err := WalkArchive(data, format, func(name string, fileInfo fs.FileInfo, r io.Reader, linkname string) error { + if external.StripComponents > 0 { + components := strings.Split(name, "/") + if len(components) <= external.StripComponents { + return nil + } + name = path.Join(components[external.StripComponents:]...) + } + switch { + case name == "": + return nil + case name != external.ArchivePath: + // If this entry is a directory and it cannot contain the file we + // are looking for then skip this directory. + if fileInfo.IsDir() && !strings.HasPrefix(external.ArchivePath, name) { + return fs.SkipDir + } + return nil + case fileInfo.Mode()&fs.ModeType == 0: + contents, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + + if !external.Archive.ExtractAppleDoubleFiles && isAppleDoubleFile(name, contents) { + return nil + } + + lazyContents := newLazyContents(contents) + fileAttr := FileAttr{ + TargetName: fileInfo.Name(), + Type: SourceFileTypeFile, + Empty: fileInfo.Size() == 0, + Executable: IsExecutable(fileInfo) || external.Executable, + Private: isPrivate(fileInfo) || external.Private, + ReadOnly: isReadOnly(fileInfo) || external.ReadOnly, + } + sourceRelPath := parentSourceRelPath.Join(NewSourceRelPath(fileAttr.SourceName(s.encryption.EncryptedSuffix()))) + targetStateEntry := &TargetStateFile{ + lazyContents: lazyContents, + empty: fileAttr.Empty, + perm: fileAttr.perm() &^ s.umask, + sourceAttr: SourceAttr{ + External: true, + }, + } + sourceStateEntry = &SourceStateFile{ + lazyContents: lazyContents, + Attr: fileAttr, + origin: external, + sourceRelPath: sourceRelPath, + targetStateEntry: targetStateEntry, + } + return fs.SkipAll + case fileInfo.Mode()&fs.ModeType == fs.ModeSymlink: + fileAttr := FileAttr{ + TargetName: fileInfo.Name(), + Type: SourceFileTypeSymlink, + } + sourceRelPath := parentSourceRelPath.Join(NewSourceRelPath(fileAttr.SourceName(s.encryption.EncryptedSuffix()))) + targetStateEntry := &TargetStateSymlink{ + lazyLinkname: newLazyLinkname(linkname), + sourceAttr: SourceAttr{ + External: true, + }, + } + sourceStateEntry = &SourceStateFile{ + Attr: fileAttr, + origin: external, + sourceRelPath: sourceRelPath, + targetStateEntry: targetStateEntry, + } + return fs.SkipAll + default: + return fmt.Errorf("%s: unsupported mode %o", name, fileInfo.Mode()&fs.ModeType) + } + }); err != nil { + return nil, err + } + if sourceStateEntry == nil { + return nil, fmt.Errorf("%s: path not found in %s", external.ArchivePath, external.URL) + } + + return s.populateImplicitParentDirs(externalRelPath, external, map[RelPath][]SourceStateEntry{ + externalRelPath: {sourceStateEntry}, + }), nil +} + +// ReadExternalDir returns all source state entries in an external_ dir. +func (s *SourceState) readExternalDir( + rootSourceAbsPath AbsPath, + rootSourceRelPath SourceRelPath, + rootTargetRelPath RelPath, +) (map[RelPath][]SourceStateEntry, error) { + sourceStateEntries := make(map[RelPath][]SourceStateEntry) + walkFunc := func(absPath AbsPath, fileInfo fs.FileInfo, err error) error { + switch { + case err != nil: + return err + case absPath == rootSourceAbsPath: + return nil + } + relPath := absPath.MustTrimDirPrefix(rootSourceAbsPath) + targetRelPath := rootTargetRelPath.Join(relPath) + if s.Ignore(targetRelPath) { + if fileInfo.IsDir() { + return fs.SkipDir + } + return nil + } + var sourceStateEntry SourceStateEntry + switch fileInfo.Mode().Type() { + case 0: + fileAttr := FileAttr{ + TargetName: fileInfo.Name(), + Type: SourceFileTypeFile, + Empty: true, + Executable: IsExecutable(fileInfo), + Private: isPrivate(fileInfo), + ReadOnly: isReadOnly(fileInfo), + } + lazyContents := newLazyContentsFunc(func() ([]byte, error) { + return s.system.ReadFile(absPath) + }) + sourceStateEntry = &SourceStateFile{ + lazyContents: lazyContents, + origin: SourceStateOriginAbsPath(absPath), + Attr: fileAttr, + sourceRelPath: rootSourceRelPath.Join(relPath.SourceRelPath()), + targetStateEntry: &TargetStateFile{ + lazyContents: lazyContents, + empty: true, + perm: fileAttr.perm() &^ s.umask, + }, + } + case fs.ModeDir: + dirAttr := DirAttr{ + TargetName: fileInfo.Name(), + Exact: true, + Private: isPrivate(fileInfo), + ReadOnly: isReadOnly(fileInfo), + } + sourceStateEntry = &SourceStateDir{ + origin: SourceStateOriginAbsPath(absPath), + sourceRelPath: rootSourceRelPath.Join(relPath.SourceRelDirPath()), + Attr: dirAttr, + targetStateEntry: &TargetStateDir{ + perm: dirAttr.perm() &^ s.umask, + }, + } + case fs.ModeSymlink: + fileAttr := FileAttr{ + TargetName: fileInfo.Name(), + Type: SourceFileTypeFile, + } + lazyLinkname := newLazyLinknameFunc(func() (string, error) { + return s.system.Readlink(absPath) + }) + sourceStateEntry = &SourceStateFile{ + lazyContents: newLazyContentsFunc(func() ([]byte, error) { + linkname, err := lazyLinkname.Linkname() + if err != nil { + return nil, err + } + return []byte(linkname), nil + }), + origin: SourceStateOriginAbsPath(absPath), + Attr: fileAttr, + sourceRelPath: rootSourceRelPath.Join(relPath.SourceRelPath()), + targetStateEntry: &TargetStateSymlink{ + lazyLinkname: lazyLinkname, + }, + } + } + sourceStateEntries[targetRelPath] = append(sourceStateEntries[targetRelPath], sourceStateEntry) + return nil + } + if err := Walk(s.system, rootSourceAbsPath, walkFunc); err != nil { + return nil, err + } return sourceStateEntries, nil } // readExternalFile reads an external file and returns its SourceStateEntries. func (s *SourceState) readExternalFile( - ctx context.Context, externalRelPath RelPath, parentSourceRelPath SourceRelPath, external External, + ctx context.Context, + externalRelPath RelPath, + parentSourceRelPath SourceRelPath, + external *External, options *ReadOptions, ) (map[RelPath][]SourceStateEntry, error) { lazyContents := newLazyContentsFunc(func() ([]byte, error) { @@ -1796,32 +2681,138 @@ func (s *SourceState) readExternalFile( fileAttr := FileAttr{ Empty: true, Executable: external.Executable, + Private: external.Private, + ReadOnly: external.ReadOnly, } targetStateEntry := &TargetStateFile{ lazyContents: lazyContents, empty: fileAttr.Empty, perm: fileAttr.perm() &^ s.umask, + sourceAttr: SourceAttr{ + External: true, + }, } sourceStateEntry := &SourceStateFile{ - origin: external.URL, - sourceRelPath: parentSourceRelPath.Join(NewSourceRelPath(fileAttr.SourceName(s.encryption.EncryptedSuffix()))), + origin: external, + sourceRelPath: parentSourceRelPath.Join( + NewSourceRelPath(fileAttr.SourceName(s.encryption.EncryptedSuffix())), + ), targetStateEntry: targetStateEntry, } - return map[RelPath][]SourceStateEntry{ + return s.populateImplicitParentDirs(externalRelPath, external, map[RelPath][]SourceStateEntry{ externalRelPath: {sourceStateEntry}, - }, nil + }), nil +} + +// readScriptsDir reads all scripts in scriptsDirAbsPath. +func (s *SourceState) readScriptsDir(ctx context.Context, scriptsDirAbsPath AbsPath) (map[RelPath][]SourceStateEntry, error) { + var allSourceStateEntriesMu sync.Mutex + allSourceStateEntries := make(map[RelPath][]SourceStateEntry) + addSourceStateEntry := func(relPath RelPath, sourceStateEntry SourceStateEntry) { + allSourceStateEntriesMu.Lock() + allSourceStateEntries[relPath] = append(allSourceStateEntries[relPath], sourceStateEntry) + allSourceStateEntriesMu.Unlock() + } + walkFunc := func(ctx context.Context, sourceAbsPath AbsPath, fileInfo fs.FileInfo, err error) error { + if err != nil { + return err + } + if sourceAbsPath == scriptsDirAbsPath { + return nil + } + + // Follow symlinks in the source directory. + if fileInfo.Mode().Type() == fs.ModeSymlink { + // Some programs (notably emacs) use invalid symlinks as lockfiles. + // To avoid following them and getting an ENOENT error, check first + // if this is an entry that we will ignore anyway. + if strings.HasPrefix(fileInfo.Name(), ignorePrefix) && !strings.HasPrefix(fileInfo.Name(), Prefix) { + return nil + } + fileInfo, err = s.system.Stat(sourceAbsPath) + if err != nil { + return err + } + } + + sourceRelPath := SourceRelPath{ + relPath: sourceAbsPath.MustTrimDirPrefix(s.sourceDirAbsPath), + isDir: fileInfo.IsDir(), + } + parentSourceRelPath, sourceName := sourceRelPath.Split() + + switch { + case err != nil: + return err + case strings.HasPrefix(fileInfo.Name(), Prefix): + return fmt.Errorf("%s: not allowed in %s directory", sourceAbsPath, scriptsDirName) + case strings.HasPrefix(fileInfo.Name(), ignorePrefix): + if fileInfo.IsDir() { + return fs.SkipDir + } + return nil + case fileInfo.IsDir(): + return nil + case fileInfo.Mode().IsRegular(): + fa := parseFileAttr(sourceName.String(), s.encryption.EncryptedSuffix()) + if fa.Type != SourceFileTypeScript { + return fmt.Errorf("%s: not a script", sourceAbsPath) + } + targetRelPath := parentSourceRelPath.Dir().TargetRelPath(s.encryption.EncryptedSuffix()).JoinString(fa.TargetName) + if s.Ignore(targetRelPath) { + return nil + } + var sourceStateEntry SourceStateEntry + targetRelPath, sourceStateEntry = s.newSourceStateFile(sourceAbsPath, sourceRelPath, fa, targetRelPath) + addSourceStateEntry(targetRelPath, sourceStateEntry) + return nil + default: + return &unsupportedFileTypeError{ + absPath: sourceAbsPath, + mode: fileInfo.Mode(), + } + } + } + if err := concurrentWalkSourceDir(ctx, s.system, scriptsDirAbsPath, walkFunc); err != nil { + return nil, err + } + return allSourceStateEntries, nil +} + +// readVersionFile reads a .chezmoiversion file from sourceAbsPath and returns +// an error if the version is newer that s's version. +func (s *SourceState) readVersionFile(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 fmt.Errorf("%s: %q: %w", sourceAbsPath, data, err) + } + var zeroVersion semver.Version + if s.version != zeroVersion && s.version.LessThan(*version) { + return &TooOldError{ + Have: s.version, + Need: *version, + } + } + return nil } // sourceStateEntry returns a new SourceStateEntry based on actualStateEntry. func (s *SourceState) sourceStateEntry( - actualStateEntry ActualStateEntry, destAbsPath AbsPath, fileInfo fs.FileInfo, parentSourceRelPath SourceRelPath, + actualStateEntry ActualStateEntry, + destAbsPath AbsPath, + fileInfo fs.FileInfo, + parentSourceRelPath SourceRelPath, options *AddOptions, ) (SourceStateEntry, error) { switch actualStateEntry := actualStateEntry.(type) { case *ActualStateAbsent: return nil, fmt.Errorf("%s: not found", destAbsPath) case *ActualStateDir: - return s.newSourceStateDirEntry(fileInfo, parentSourceRelPath, options) + return s.newSourceStateDirEntry(actualStateEntry, fileInfo, parentSourceRelPath, options), nil case *ActualStateFile: return s.newSourceStateFileEntryFromFile(actualStateEntry, fileInfo, parentSourceRelPath, options) case *ActualStateSymlink: @@ -1831,21 +2822,66 @@ func (s *SourceState) sourceStateEntry( } } -// allEquivalentDirs returns if sourceStateEntries are all equivalent -// directories. -func allEquivalentDirs(sourceStateEntries []SourceStateEntry) bool { - sourceStateDir0, ok := sourceStateEntries[0].(*SourceStateDir) - if !ok { - return false +func (e *External) Path() AbsPath { + return e.sourceAbsPath +} + +func (e *External) OriginString() string { + return e.URL + " defined in " + e.sourceAbsPath.String() +} + +// canonicalSourceStateEntry returns the canonical SourceStateEntry for the +// given sourceStateEntries. +// +// This only applies to directories, where SourceStateImplicitDirs are +// considered equivalent to all SourceStateDirs. +func canonicalSourceStateEntry(sourceStateEntries []SourceStateEntry) (SourceStateEntry, bool) { + // Find all directories to check for equivalence. + var firstSourceStateDir *SourceStateDir + sourceStateDirs := make([]SourceStateEntry, len(sourceStateEntries)) + for i, sourceStateEntry := range sourceStateEntries { + switch sourceStateEntry := sourceStateEntry.(type) { + case *SourceStateDir: + firstSourceStateDir = sourceStateEntry + sourceStateDirs[i] = sourceStateEntry + case *SourceStateImplicitDir: + sourceStateDirs[i] = sourceStateEntry + default: + return nil, false + } } - for _, sourceStateEntry := range sourceStateEntries[1:] { - sourceStateDir, ok := sourceStateEntry.(*SourceStateDir) - if !ok { - return false + + switch len(sourceStateDirs) { + case 0: + // If there are no SourceStateDirs then there are no equivalent directories. + return nil, false + case 1: + return sourceStateDirs[0], true + default: + // Check for equivalence. + for _, sourceStateDir := range sourceStateDirs { + switch sourceStateDir := sourceStateDir.(type) { + case *SourceStateDir: + if sourceStateDir.Attr != firstSourceStateDir.Attr { + return nil, false + } + case *SourceStateImplicitDir: + // SourceStateImplicitDirs are considered equivalent to all other + // directories. + } } - if sourceStateDir0.Attr != sourceStateDir.Attr { - return false + // If all directories are equivalent then return the first real + // *SourceStateDir, if it exists. + if firstSourceStateDir != nil { + return firstSourceStateDir, true } + // Otherwise, return the first entry which is a *SourceStateImplicitDir. + return sourceStateDirs[0], true } - return true +} + +// isAppleDoubleFile returns true if the file looks like and has the +// expected signature of an AppleDouble file. +func isAppleDoubleFile(name string, contents []byte) bool { + return strings.HasPrefix(path.Base(name), appleDoubleNamePrefix) && bytes.HasPrefix(contents, appleDoubleContentsPrefix) } diff --git a/internal/chezmoi/sourcestate_test.go b/internal/chezmoi/sourcestate_test.go index a21e6fcb9e2..6baa10b3b17 100644 --- a/internal/chezmoi/sourcestate_test.go +++ b/internal/chezmoi/sourcestate_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "errors" + "fmt" "io/fs" "net/http" "net/http/httptest" @@ -13,11 +14,10 @@ import ( "text/template" "time" + "github.com/alecthomas/assert/v2" "github.com/coreos/go-semver/semver" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - vfs "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + vfs "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -27,8 +27,8 @@ func TestSourceStateAdd(t *testing.T) { name string destAbsPaths []AbsPath addOptions AddOptions - extraRoot interface{} - tests []interface{} + extraRoot any + tests []any }{ { name: "dir", @@ -36,18 +36,18 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.dir"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), }, }, @@ -57,25 +57,25 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.dir"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - extraRoot: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".local/share/chezmoi/exact_dot_dir/file": "# contents of file\n", + extraRoot: map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi/exact_dot_dir/file": "# contents of .dir/file\n", }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/exact_dot_dir", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), - vfst.TestContentsString("# contents of file\n"), + vfst.TestContentsString("# contents of .dir/file\n"), ), }, }, @@ -85,15 +85,15 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.dir/file"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .dir/file\n"), ), @@ -105,14 +105,14 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.dir/file"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - extraRoot: map[string]interface{}{ - "/home/user/.local/share/chezmoi/dot_dir": &vfst.Dir{Perm: 0o777}, + extraRoot: map[string]any{ + "/home/user/.local/share/chezmoi/dot_dir": &vfst.Dir{Perm: fs.ModePerm}, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestContentsString("# contents of .dir/file\n"), ), }, @@ -123,19 +123,19 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.dir/subdir"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir/file", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), }, }, @@ -145,22 +145,22 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.dir/subdir/file"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .dir/subdir/file\n"), ), @@ -172,14 +172,14 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.dir/subdir/file"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - extraRoot: map[string]interface{}{ - "/home/user/.local/share/chezmoi/dot_dir/subdir": &vfst.Dir{Perm: 0o777}, + extraRoot: map[string]any{ + "/home/user/.local/share/chezmoi/dot_dir/subdir": &vfst.Dir{Perm: fs.ModePerm}, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/subdir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestContentsString("# contents of .dir/subdir/file\n"), ), }, @@ -190,15 +190,15 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.readonly_dir"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - extraRoot: map[string]interface{}{ + extraRoot: map[string]any{ "/home/user/.readonly_dir": &vfst.Dir{Perm: 0o555}, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/readonly_dot_readonly_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), }, }, @@ -208,11 +208,11 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.empty"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_empty", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), }, }, @@ -222,12 +222,11 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.empty"), }, addOptions: AddOptions{ - Empty: true, - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/empty_dot_empty", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContents(nil), ), @@ -239,11 +238,11 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.executable"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/executable_dot_executable", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .executable\n"), ), @@ -255,11 +254,11 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.executable"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_executable", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .executable\n"), ), @@ -271,12 +270,12 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.create"), }, addOptions: AddOptions{ - Create: true, - Include: NewEntryTypeSet(EntryTypesAll), + Create: true, + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/create_dot_create", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .create\n"), ), @@ -288,11 +287,11 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.file"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .file\n"), ), @@ -304,19 +303,19 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.file"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - extraRoot: map[string]interface{}{ + extraRoot: map[string]any{ "/home/user/.local/share/chezmoi/executable_dot_file": "# contents of .file\n", }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .file\n"), ), vfst.TestPath("/home/user/.local/share/chezmoi/executable_dot_file", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), }, }, @@ -326,14 +325,14 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.file"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - extraRoot: map[string]interface{}{ + extraRoot: map[string]any{ "/home/user/.local/share/chezmoi/dot_file": "# old contents of .file\n", }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .file\n"), ), @@ -345,11 +344,11 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.private"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_private", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .private\n"), ), @@ -361,11 +360,11 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.private"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_private", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .private\n"), ), @@ -377,17 +376,17 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.readonly"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - extraRoot: map[string]interface{}{ + extraRoot: map[string]any{ "/home/user/.readonly": &vfst.File{ Perm: 0o444, Contents: []byte("# contents of .readonly\n"), }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/readonly_dot_readonly", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .readonly\n"), ), @@ -399,11 +398,11 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.symlink"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/symlink_dot_symlink", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestContentsString(".dir/subdir/file\n"), ), }, @@ -414,16 +413,16 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.symlink_windows"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - extraRoot: map[string]interface{}{ - "/home/user": map[string]interface{}{ + extraRoot: map[string]any{ + "/home/user": map[string]any{ ".symlink_windows": &vfst.Symlink{Target: ".dir\\subdir\\file"}, }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/symlink_dot_symlink_windows", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestContentsString(".dir/subdir/file\n"), ), }, @@ -435,11 +434,11 @@ func TestSourceStateAdd(t *testing.T) { }, addOptions: AddOptions{ AutoTemplate: true, - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_template.tmpl", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("key = {{ .variable }}\n"), ), @@ -452,15 +451,15 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.dir/file"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .dir/file\n"), ), @@ -472,14 +471,14 @@ func TestSourceStateAdd(t *testing.T) { NewAbsPath("/home/user/.dir/subdir/file"), }, addOptions: AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), }, - extraRoot: map[string]interface{}{ - "/home/user/.local/share/chezmoi/dot_dir/exact_subdir": &vfst.Dir{Perm: 0o777}, + extraRoot: map[string]any{ + "/home/user/.local/share/chezmoi/dot_dir/exact_subdir": &vfst.Dir{Perm: fs.ModePerm}, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/exact_subdir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestContentsString("# contents of .dir/subdir/file\n"), ), }, @@ -488,24 +487,24 @@ func TestSourceStateAdd(t *testing.T) { t.Run(tc.name, func(t *testing.T) { chezmoitest.SkipUnlessGOOS(t, tc.name) - chezmoitest.WithTestFS(t, map[string]interface{}{ - "/home/user": map[string]interface{}{ + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user": map[string]any{ ".create": "# contents of .create\n", - ".dir": map[string]interface{}{ + ".dir": map[string]any{ "file": "# contents of .dir/file\n", - "subdir": map[string]interface{}{ + "subdir": map[string]any{ "file": "# contents of .dir/subdir/file\n", }, }, ".empty": "", ".executable": &vfst.File{ - Perm: 0o777, + Perm: fs.ModePerm, Contents: []byte("# contents of .executable\n"), }, ".file": "# contents of .file\n", - ".local": map[string]interface{}{ - "share": map[string]interface{}{ - "chezmoi": &vfst.Dir{Perm: 0o777}, + ".local": map[string]any{ + "share": map[string]any{ + "chezmoi": &vfst.Dir{Perm: fs.ModePerm}, }, }, ".private": &vfst.File{ @@ -520,7 +519,7 @@ func TestSourceStateAdd(t *testing.T) { system := NewRealSystem(fileSystem) persistentState := NewMockPersistentState() if tc.extraRoot != nil { - require.NoError(t, vfst.NewBuilder().Build(system.UnderlyingFS(), tc.extraRoot)) + assert.NoError(t, vfst.NewBuilder().Build(system.UnderlyingFS(), tc.extraRoot)) } s := NewSourceState( @@ -528,18 +527,18 @@ func TestSourceStateAdd(t *testing.T) { WithDestDir(NewAbsPath("/home/user")), WithSourceDir(NewAbsPath("/home/user/.local/share/chezmoi")), WithSystem(system), - withUserTemplateData(map[string]interface{}{ + withUserTemplateData(map[string]any{ "variable": "value", }), ) - require.NoError(t, s.Read(ctx, nil)) + assert.NoError(t, s.Read(ctx, nil)) requireEvaluateAll(t, s, system) destAbsPathInfos := make(map[AbsPath]fs.FileInfo) for _, destAbsPath := range tc.destAbsPaths { - require.NoError(t, s.AddDestAbsPathInfos(destAbsPathInfos, system, destAbsPath, nil)) + assert.NoError(t, s.AddDestAbsPathInfos(destAbsPathInfos, system, destAbsPath, nil)) } - require.NoError(t, s.Add(system, persistentState, system, destAbsPathInfos, &tc.addOptions)) + assert.NoError(t, s.Add(system, persistentState, system, destAbsPathInfos, &tc.addOptions)) vfst.RunTests(t, fileSystem, "", tc.tests...) }) @@ -549,29 +548,29 @@ func TestSourceStateAdd(t *testing.T) { func TestSourceStateAddInExternal(t *testing.T) { buffer := &bytes.Buffer{} - tarWriterSystem := NewTARWriterSystem(buffer, tar.Header{}) - require.NoError(t, tarWriterSystem.Mkdir(NewAbsPath("dir"), 0o777)) - require.NoError(t, tarWriterSystem.WriteFile(NewAbsPath("dir/file"), []byte("# contents of dir/file\n"), 0o666)) - require.NoError(t, tarWriterSystem.Close()) + tarWriterSystem := NewTarWriterSystem(buffer, tar.Header{}) + assert.NoError(t, tarWriterSystem.Mkdir(NewAbsPath("dir"), fs.ModePerm)) + assert.NoError(t, tarWriterSystem.WriteFile(NewAbsPath("dir/file"), []byte("# contents of dir/file\n"), 0o666)) + assert.NoError(t, tarWriterSystem.Close()) archiveData := buffer.Bytes() httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := w.Write(archiveData) - require.NoError(t, err) + assert.NoError(t, err) })) defer httpServer.Close() - root := map[string]interface{}{ - "/home/user": map[string]interface{}{ + root := map[string]any{ + "/home/user": map[string]any{ ".dir/file2": "# contents of .dir/file2\n", - ".local/share/chezmoi": map[string]interface{}{ + ".local/share/chezmoi": map[string]any{ ".chezmoiexternal.toml": chezmoitest.JoinLines( `[".dir"]`, ` type = "archive"`, ` url = "`+httpServer.URL+`/archive.tar"`, ` stripComponents = 1`, ), - "dot_dir": &vfst.Dir{Perm: 0o777}, + "dot_dir": &vfst.Dir{Perm: fs.ModePerm}, }, }, } @@ -587,25 +586,25 @@ func TestSourceStateAddInExternal(t *testing.T) { WithSourceDir(NewAbsPath("/home/user/.local/share/chezmoi")), WithSystem(system), ) - require.NoError(t, s.Read(ctx, nil)) + assert.NoError(t, s.Read(ctx, nil)) destAbsPath := NewAbsPath("/home/user/.dir/file2") fileInfo, err := system.Stat(destAbsPath) - require.NoError(t, err) + assert.NoError(t, err) destAbsPathInfos := map[AbsPath]fs.FileInfo{ destAbsPath: fileInfo, } - require.NoError(t, s.Add(system, persistentState, system, destAbsPathInfos, &AddOptions{ - Include: NewEntryTypeSet(EntryTypesAll), + assert.NoError(t, s.Add(system, persistentState, system, destAbsPathInfos, &AddOptions{ + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), })) vfst.RunTests(t, fileSystem, "", vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file2", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .dir/file2\n"), ), @@ -616,68 +615,68 @@ func TestSourceStateAddInExternal(t *testing.T) { func TestSourceStateApplyAll(t *testing.T) { for _, tc := range []struct { name string - root interface{} + root any sourceStateOptions []SourceStateOption - tests []interface{} + tests []any }{ { name: "empty", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".local/share/chezmoi": &vfst.Dir{Perm: 0o777}, + root: map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": &vfst.Dir{Perm: fs.ModePerm}, }, }, }, { name: "dir", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".local/share/chezmoi": map[string]interface{}{ - "dot_dir": &vfst.Dir{Perm: 0o777}, + root: map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": map[string]any{ + "dot_dir": &vfst.Dir{Perm: fs.ModePerm}, }, }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), }, }, { name: "dir_exact", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".dir": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ + ".dir": map[string]any{ "file": "# contents of .dir/file\n", }, - ".local/share/chezmoi": map[string]interface{}{ - "exact_dot_dir": &vfst.Dir{Perm: 0o777}, + ".local/share/chezmoi": map[string]any{ + "exact_dot_dir": &vfst.Dir{Perm: fs.ModePerm}, }, }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.dir/file", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), }, }, { name: "file", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": map[string]any{ "dot_file": "# contents of .file\n", }, }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .file\n"), ), @@ -685,32 +684,32 @@ func TestSourceStateApplyAll(t *testing.T) { }, { name: "file_remove_empty", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ ".empty": "# contents of .empty\n", - ".local/share/chezmoi": map[string]interface{}{ + ".local/share/chezmoi": map[string]any{ "dot_empty": "", }, }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.empty", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), }, }, { name: "file_create_empty", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": map[string]any{ "empty_dot_empty": "", }, }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.empty", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContents(nil), ), @@ -718,21 +717,21 @@ func TestSourceStateApplyAll(t *testing.T) { }, { name: "file_template", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": map[string]any{ "dot_template.tmpl": "key = {{ .variable }}\n", }, }, }, sourceStateOptions: []SourceStateOption{ - withUserTemplateData(map[string]interface{}{ + withUserTemplateData(map[string]any{ "variable": "value", }), }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.template", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("key = value\n"), ), @@ -740,16 +739,16 @@ func TestSourceStateApplyAll(t *testing.T) { }, { name: "create", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": map[string]any{ "create_dot_create": "# contents of .create\n", }, }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.create", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .create\n"), ), @@ -757,17 +756,17 @@ func TestSourceStateApplyAll(t *testing.T) { }, { name: "create_no_replace", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": map[string]any{ "create_dot_create": "# contents of .create\n", }, ".create": "# existing contents of .create\n", }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.create", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# existing contents of .create\n"), ), @@ -775,14 +774,14 @@ func TestSourceStateApplyAll(t *testing.T) { }, { name: "symlink", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": map[string]any{ "symlink_dot_symlink": ".dir/subdir/file\n", }, }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.symlink", vfst.TestModeType(fs.ModeSymlink), vfst.TestSymlinkTarget(filepath.FromSlash(".dir/subdir/file")), @@ -791,14 +790,14 @@ func TestSourceStateApplyAll(t *testing.T) { }, { name: "symlink_template", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": map[string]any{ "symlink_dot_symlink.tmpl": `{{ ".dir/subdir/file" }}` + "\n", }, }, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.symlink", vfst.TestModeType(fs.ModeSymlink), vfst.TestSymlinkTarget(filepath.FromSlash(".dir/subdir/file")), @@ -819,12 +818,13 @@ func TestSourceStateApplyAll(t *testing.T) { } sourceStateOptions = append(sourceStateOptions, tc.sourceStateOptions...) s := NewSourceState(sourceStateOptions...) - require.NoError(t, s.Read(ctx, nil)) + assert.NoError(t, s.Read(ctx, nil)) requireEvaluateAll(t, s, system) - require.NoError(t, s.applyAll(system, system, persistentState, NewAbsPath("/home/user"), ApplyOptions{ - Include: NewEntryTypeSet(EntryTypesAll), - Umask: chezmoitest.Umask, - })) + err := s.applyAll(system, system, persistentState, NewAbsPath("/home/user"), ApplyOptions{ + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), + Umask: chezmoitest.Umask, + }) + assert.NoError(t, err) vfst.RunTests(t, fileSystem, "", tc.tests...) }) @@ -832,39 +832,88 @@ func TestSourceStateApplyAll(t *testing.T) { } } +func TestSourceStateExecuteTemplateData(t *testing.T) { + for _, tc := range []struct { + name string + dataStr string + expectedStr string + }{ + { + name: "line_ending_lf", + dataStr: "" + + "unix\n" + + "\n" + + "windows\r\n" + + "\r\n" + + "# chezmoi:template:line-ending=lf\n", + expectedStr: chezmoitest.JoinLines( + "unix", + "", + "windows", + "", + ), + }, + { + name: "line_endings_lf", + dataStr: "" + + "unix\n" + + "\n" + + "windows\r\n" + + "\r\n" + + "# chezmoi:template:line-endings=lf\n", + expectedStr: chezmoitest.JoinLines( + "unix", + "", + "windows", + "", + ), + }, + } { + t.Run(tc.name, func(t *testing.T) { + s := NewSourceState() + actual, err := s.ExecuteTemplateData(ExecuteTemplateDataOptions{ + Name: tc.name, + Data: []byte(tc.dataStr), + }) + assert.NoError(t, err) + assert.Equal(t, tc.expectedStr, string(actual)) + }) + } +} + func TestSourceStateRead(t *testing.T) { for _, tc := range []struct { name string - root interface{} + root any expectedError string expectedSourceState *SourceState }{ { name: "empty", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": &vfst.Dir{Perm: 0o777}, + root: map[string]any{ + "/home/user/.local/share/chezmoi": &vfst.Dir{Perm: fs.ModePerm}, }, expectedSourceState: NewSourceState(), }, { name: "dir", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ "dir": &vfst.Dir{ - Perm: 0o777 &^ chezmoitest.Umask, + Perm: fs.ModePerm &^ chezmoitest.Umask, }, }, }, expectedSourceState: NewSourceState( withEntries(map[RelPath]SourceStateEntry{ NewRelPath("dir"): &SourceStateDir{ - origin: "dir", + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/dir")), sourceRelPath: NewSourceRelDirPath("dir"), Attr: DirAttr{ TargetName: "dir", }, targetStateEntry: &TargetStateDir{ - perm: 0o777 &^ chezmoitest.Umask, + perm: fs.ModePerm &^ chezmoitest.Umask, }, }, }), @@ -872,15 +921,15 @@ func TestSourceStateRead(t *testing.T) { }, { name: "file", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ "dot_file": "# contents of .file\n", }, }, expectedSourceState: NewSourceState( withEntries(map[RelPath]SourceStateEntry{ NewRelPath(".file"): &SourceStateFile{ - origin: "dot_file", + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/dot_file")), sourceRelPath: NewSourceRelPath("dot_file"), Attr: FileAttr{ TargetName: ".file", @@ -897,42 +946,42 @@ func TestSourceStateRead(t *testing.T) { }, { name: "duplicate_target_file", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ "dot_file": "# contents of .file\n", "dot_file.tmpl": "# contents of .file\n", }, }, - expectedError: ".file: inconsistent state (dot_file, dot_file.tmpl)", + expectedError: ".file: inconsistent state (/home/user/.local/share/chezmoi/dot_file, /home/user/.local/share/chezmoi/dot_file.tmpl)", }, { name: "duplicate_target_dir", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ "dir": &vfst.Dir{ - Perm: 0o777 &^ chezmoitest.Umask, + Perm: fs.ModePerm &^ chezmoitest.Umask, }, "exact_dir": &vfst.Dir{ - Perm: 0o777 &^ chezmoitest.Umask, + Perm: fs.ModePerm &^ chezmoitest.Umask, }, }, }, - expectedError: "dir: inconsistent state (dir, exact_dir)", + expectedError: "dir: inconsistent state (/home/user/.local/share/chezmoi/dir, /home/user/.local/share/chezmoi/exact_dir)", }, { name: "duplicate_target_script", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ "run_script": "#!/bin/sh\n", "run_once_script": "#!/bin/sh\n", }, }, - expectedError: "script: inconsistent state (run_once_script, run_script)", + expectedError: "script: inconsistent state (/home/user/.local/share/chezmoi/run_once_script, /home/user/.local/share/chezmoi/run_script)", }, { name: "symlink_with_attr", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ ".file": "# contents of .file\n", "executable_dot_file": &vfst.Symlink{Target: ".file"}, }, @@ -940,7 +989,7 @@ func TestSourceStateRead(t *testing.T) { expectedSourceState: NewSourceState( withEntries(map[RelPath]SourceStateEntry{ NewRelPath(".file"): &SourceStateFile{ - origin: "executable_dot_file", + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/executable_dot_file")), sourceRelPath: NewSourceRelPath("executable_dot_file"), Attr: FileAttr{ TargetName: ".file", @@ -949,7 +998,7 @@ func TestSourceStateRead(t *testing.T) { }, lazyContents: newLazyContents([]byte("# contents of .file\n")), targetStateEntry: &TargetStateFile{ - perm: 0o777 &^ chezmoitest.Umask, + perm: fs.ModePerm &^ chezmoitest.Umask, lazyContents: newLazyContents([]byte("# contents of .file\n")), }, }, @@ -958,8 +1007,8 @@ func TestSourceStateRead(t *testing.T) { }, { name: "symlink_script", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ ".script": "# contents of .script\n", "run_script": &vfst.Symlink{Target: ".script"}, }, @@ -967,16 +1016,22 @@ func TestSourceStateRead(t *testing.T) { expectedSourceState: NewSourceState( withEntries(map[RelPath]SourceStateEntry{ NewRelPath("script"): &SourceStateFile{ - origin: "run_script", + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/run_script")), sourceRelPath: NewSourceRelPath("run_script"), Attr: FileAttr{ TargetName: "script", Type: SourceFileTypeScript, + Condition: ScriptConditionAlways, }, lazyContents: newLazyContents([]byte("# contents of .script\n")), targetStateEntry: &TargetStateScript{ name: NewRelPath("script"), lazyContents: newLazyContents([]byte("# contents of .script\n")), + condition: ScriptConditionAlways, + sourceAttr: SourceAttr{ + Condition: ScriptConditionAlways, + }, + sourceRelPath: NewSourceRelPath("run_script"), }, }, }), @@ -984,24 +1039,30 @@ func TestSourceStateRead(t *testing.T) { }, { name: "script", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ "run_script": "# contents of script\n", }, }, expectedSourceState: NewSourceState( withEntries(map[RelPath]SourceStateEntry{ NewRelPath("script"): &SourceStateFile{ - origin: "run_script", + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/run_script")), sourceRelPath: NewSourceRelPath("run_script"), Attr: FileAttr{ TargetName: "script", Type: SourceFileTypeScript, + Condition: ScriptConditionAlways, }, lazyContents: newLazyContents([]byte("# contents of script\n")), targetStateEntry: &TargetStateScript{ name: NewRelPath("script"), lazyContents: newLazyContents([]byte("# contents of script\n")), + condition: ScriptConditionAlways, + sourceAttr: SourceAttr{ + Condition: ScriptConditionAlways, + }, + sourceRelPath: NewSourceRelPath("run_script"), }, }, }), @@ -1009,15 +1070,15 @@ func TestSourceStateRead(t *testing.T) { }, { name: "symlink", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ "symlink_dot_symlink": ".dir/subdir/file", }, }, expectedSourceState: NewSourceState( withEntries(map[RelPath]SourceStateEntry{ NewRelPath(".symlink"): &SourceStateFile{ - origin: "symlink_dot_symlink", + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/symlink_dot_symlink")), sourceRelPath: NewSourceRelPath("symlink_dot_symlink"), Attr: FileAttr{ TargetName: ".symlink", @@ -1033,9 +1094,9 @@ func TestSourceStateRead(t *testing.T) { }, { name: "file_in_dir", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ - "dir": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ + "dir": map[string]any{ "file": "# contents of .dir/file\n", }, }, @@ -1043,17 +1104,17 @@ func TestSourceStateRead(t *testing.T) { expectedSourceState: NewSourceState( withEntries(map[RelPath]SourceStateEntry{ NewRelPath("dir"): &SourceStateDir{ - origin: "dir", + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/dir")), sourceRelPath: NewSourceRelDirPath("dir"), Attr: DirAttr{ TargetName: "dir", }, targetStateEntry: &TargetStateDir{ - perm: 0o777 &^ chezmoitest.Umask, + perm: fs.ModePerm &^ chezmoitest.Umask, }, }, NewRelPath("dir/file"): &SourceStateFile{ - origin: "dir/file", + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/dir/file")), sourceRelPath: NewSourceRelPath("dir/file"), Attr: FileAttr{ TargetName: "file", @@ -1070,46 +1131,49 @@ func TestSourceStateRead(t *testing.T) { }, { name: "chezmoiignore", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ ".chezmoiignore": "README.md\n", }, }, expectedSourceState: NewSourceState( withIgnore( - mustNewPatternSet(t, map[string]bool{ - "README.md": true, + mustNewPatternSet(t, map[string]patternSetIncludeType{ + "README.md": patternSetInclude, }), ), ), }, { name: "chezmoiignore_ignore_file", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ ".chezmoiignore": "README.md\n", "README.md": "", }, }, expectedSourceState: NewSourceState( withIgnore( - mustNewPatternSet(t, map[string]bool{ - "README.md": true, + mustNewPatternSet(t, map[string]patternSetIncludeType{ + "README.md": patternSetInclude, }), ), + withIgnoredRelPathStrs( + "README.md", + ), ), }, { name: "chezmoiignore_exact_dir", - root: map[string]interface{}{ - "/home/user/dir": map[string]interface{}{ + root: map[string]any{ + "/home/user/dir": map[string]any{ "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{}{ + "/home/user/.local/share/chezmoi": map[string]any{ ".chezmoiignore": "dir/file3\n", - "exact_dir": map[string]interface{}{ + "exact_dir": map[string]any{ "file1": "# contents of dir/file1\n", }, }, @@ -1117,18 +1181,18 @@ func TestSourceStateRead(t *testing.T) { expectedSourceState: NewSourceState( withEntries(map[RelPath]SourceStateEntry{ NewRelPath("dir"): &SourceStateDir{ - origin: "exact_dir", + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/exact_dir")), sourceRelPath: NewSourceRelDirPath("exact_dir"), Attr: DirAttr{ TargetName: "dir", Exact: true, }, targetStateEntry: &TargetStateDir{ - perm: 0o777 &^ chezmoitest.Umask, + perm: fs.ModePerm &^ chezmoitest.Umask, }, }, NewRelPath("dir/file1"): &SourceStateFile{ - origin: "exact_dir/file1", + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/exact_dir/file1")), sourceRelPath: NewSourceRelPath("exact_dir/file1"), Attr: FileAttr{ TargetName: "file1", @@ -1141,40 +1205,52 @@ func TestSourceStateRead(t *testing.T) { }, }, NewRelPath("dir/file2"): &SourceStateRemove{ + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/exact_dir")), + sourceRelPath: NewSourceRelDirPath("exact_dir"), targetRelPath: NewRelPath("dir/file2"), }, }), withIgnore( - mustNewPatternSet(t, map[string]bool{ - "dir/file3": true, + mustNewPatternSet(t, map[string]patternSetIncludeType{ + "dir/file3": patternSetInclude, }), ), + withIgnoredRelPathStrs( + "dir/file3", + ), ), }, { name: "chezmoiremove", - root: map[string]interface{}{ + root: map[string]any{ "/home/user/file": "", - "/home/user/.local/share/chezmoi": map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]any{ ".chezmoiremove": "file\n", }, }, expectedSourceState: NewSourceState( withEntries(map[RelPath]SourceStateEntry{ NewRelPath("file"): &SourceStateRemove{ + origin: SourceStateOriginRemove{}, + sourceRelPath: NewSourceRelPath(".chezmoiremove"), targetRelPath: NewRelPath("file"), }, }), + withRemove( + mustNewPatternSet(t, map[string]patternSetIncludeType{ + "file": patternSetInclude, + }), + ), ), }, { name: "chezmoiremove_and_ignore", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ "file1": "", "file2": "", }, - "/home/user/.local/share/chezmoi": map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]any{ ".chezmoiignore": "file2\n", ".chezmoiremove": "file*\n", }, @@ -1182,87 +1258,208 @@ func TestSourceStateRead(t *testing.T) { expectedSourceState: NewSourceState( withEntries(map[RelPath]SourceStateEntry{ NewRelPath("file1"): &SourceStateRemove{ + origin: SourceStateOriginRemove{}, + sourceRelPath: NewSourceRelPath(".chezmoiremove"), targetRelPath: NewRelPath("file1"), }, }), withIgnore( - mustNewPatternSet(t, map[string]bool{ - "file2": true, + mustNewPatternSet(t, map[string]patternSetIncludeType{ + "file2": patternSetInclude, + }), + ), + withIgnoredRelPathStrs( + "file2", + ), + withRemove( + mustNewPatternSet(t, map[string]patternSetIncludeType{ + "file*": patternSetInclude, }), ), ), }, { - name: "chezmoitemplates", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ - ".chezmoitemplates": map[string]interface{}{ - "template": "# contents of .chezmoitemplates/template\n", + name: "chezmoiremove_and_ignore_in_subdir", + root: map[string]any{ + "/home/user": map[string]any{ + "dir": map[string]any{ + "file1": "", + "file2": "", }, }, - }, - 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", + "/home/user/.local/share/chezmoi": map[string]any{ + "dir/.chezmoiignore": "file2\n", + "dir/.chezmoiremove": "file*\n", }, }, expectedSourceState: NewSourceState( - withMinVersion( - semver.Version{ - Major: 1, - Minor: 2, - Patch: 3, + withEntries(map[RelPath]SourceStateEntry{ + NewRelPath("dir"): &SourceStateDir{ + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/dir")), + sourceRelPath: NewSourceRelDirPath("dir"), + Attr: DirAttr{ + TargetName: "dir", + }, + targetStateEntry: &TargetStateDir{ + perm: fs.ModePerm &^ chezmoitest.Umask, + }, + }, + NewRelPath("dir/file1"): &SourceStateRemove{ + origin: SourceStateOriginRemove{}, + sourceRelPath: NewSourceRelPath(".chezmoiremove"), + targetRelPath: NewRelPath("dir/file1"), }, + }), + withIgnore( + mustNewPatternSet(t, map[string]patternSetIncludeType{ + "dir/file2": patternSetInclude, + }), + ), + withIgnoredRelPathStrs( + "dir/file2", + ), + withRemove( + mustNewPatternSet(t, map[string]patternSetIncludeType{ + "dir/file*": patternSetInclude, + }), ), ), }, { - 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", + name: "external", + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ + "external_dir": map[string]any{ + "dot_file": "# contents of dir/dot_file\n", + "subdir": map[string]any{ + "empty_file": "", + }, + "symlink": &vfst.Symlink{Target: "dot_file"}, }, }, }, expectedSourceState: NewSourceState( withEntries(map[RelPath]SourceStateEntry{ NewRelPath("dir"): &SourceStateDir{ - origin: "dir", - sourceRelPath: NewSourceRelDirPath("dir"), + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/external_dir")), + sourceRelPath: NewSourceRelDirPath("external_dir"), Attr: DirAttr{ TargetName: "dir", + External: true, }, targetStateEntry: &TargetStateDir{ - perm: 0o777 &^ chezmoitest.Umask, + perm: fs.ModePerm &^ chezmoitest.Umask, + }, + }, + NewRelPath("dir/dot_file"): &SourceStateFile{ + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/external_dir/dot_file")), + sourceRelPath: NewSourceRelPath("external_dir/dot_file"), + Attr: FileAttr{ + TargetName: "dot_file", + Type: SourceFileTypeFile, + Empty: true, + }, + lazyContents: newLazyContents([]byte("# contents of dir/dot_file\n")), + targetStateEntry: &TargetStateFile{ + empty: true, + perm: 0o666 &^ chezmoitest.Umask, + lazyContents: newLazyContents([]byte("# contents of dir/dot_file\n")), + }, + }, + NewRelPath("dir/subdir"): &SourceStateDir{ + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/external_dir/subdir")), + sourceRelPath: NewSourceRelDirPath("external_dir/subdir"), + Attr: DirAttr{ + TargetName: "subdir", + Exact: true, + }, + targetStateEntry: &TargetStateDir{ + perm: fs.ModePerm &^ chezmoitest.Umask, + }, + }, + NewRelPath("dir/subdir/empty_file"): &SourceStateFile{ + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/external_dir/subdir/empty_file")), + sourceRelPath: NewSourceRelPath("external_dir/subdir/empty_file"), + Attr: FileAttr{ + TargetName: "empty_file", + Type: SourceFileTypeFile, + Empty: true, + }, + lazyContents: newLazyContents([]byte{}), + targetStateEntry: &TargetStateFile{ + empty: true, + perm: 0o666 &^ chezmoitest.Umask, + lazyContents: newLazyContents([]byte{}), + }, + }, + NewRelPath("dir/symlink"): &SourceStateFile{ + origin: SourceStateOriginAbsPath(NewAbsPath("/home/user/.local/share/chezmoi/external_dir/symlink")), + sourceRelPath: NewSourceRelPath("external_dir/symlink"), + Attr: FileAttr{ + TargetName: "symlink", + Type: SourceFileTypeFile, + }, + lazyContents: newLazyContents([]byte("dot_file")), + targetStateEntry: &TargetStateSymlink{ + lazyLinkname: newLazyLinkname("dot_file"), }, }, }), - withMinVersion( - semver.Version{ - Major: 2, - Minor: 3, - Patch: 4, + ), + }, + { + name: "chezmoitemplates", + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ + ".chezmoitemplates": map[string]any{ + "template": "# contents of .chezmoitemplates/template\n", + }, + }, + }, + expectedSourceState: NewSourceState( + withTemplates( + map[string]*Template{ + "template": { + name: "template", + template: template.Must( + template.New("template"). + Option("missingkey=error"). + Parse("# contents of .chezmoitemplates/template\n"), + ), + options: TemplateOptions{ + Options: []string{"missingkey=error"}, + }, + }, }, ), ), }, + { + name: "chezmoiversion", + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ + ".chezmoiversion": "1.2.3\n", + }, + }, + expectedSourceState: NewSourceState(), + }, + { + name: "chezmoiversion_multiple", + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ + ".chezmoiversion": "1.2.3\n", + "dir": map[string]any{ + ".chezmoiversion": "2.3.4\n", + }, + }, + }, + expectedError: "source state requires chezmoi version 2.3.4 or later, chezmoi is version 1.2.3", + }, { name: "ignore_dir", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ - ".dir": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ + ".dir": map[string]any{ "file": "# contents of .dir/file\n", }, }, @@ -1271,8 +1468,8 @@ func TestSourceStateRead(t *testing.T) { }, { name: "ignore_file", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ ".file": "# contents of .file\n", }, }, @@ -1288,6 +1485,11 @@ func TestSourceStateRead(t *testing.T) { WithDestDir(NewAbsPath("/home/user")), WithSourceDir(NewAbsPath("/home/user/.local/share/chezmoi")), WithSystem(system), + WithVersion(semver.Version{ + Major: 1, + Minor: 2, + Patch: 3, + }), ) err := s.Read(ctx, nil) if tc.expectedError != "" { @@ -1295,15 +1497,16 @@ func TestSourceStateRead(t *testing.T) { assert.Equal(t, tc.expectedError, err.Error()) return } - require.NoError(t, err) + assert.NoError(t, err) requireEvaluateAll(t, s, system) tc.expectedSourceState.destDirAbsPath = NewAbsPath("/home/user") - tc.expectedSourceState.sourceDirAbsPath = NewAbsPath("/home/user/.local/share/chezmoi") + tc.expectedSourceState.sourceDirAbsPath = NewAbsPath( + "/home/user/.local/share/chezmoi", + ) requireEvaluateAll(t, tc.expectedSourceState, system) - s.baseSystem = nil - s.system = nil s.templateData = nil - assert.Equal(t, tc.expectedSourceState, s) + s.version = semver.Version{} + assert.Equal(t, tc.expectedSourceState, s, assert.Exclude[System]()) }) }) } @@ -1312,19 +1515,19 @@ func TestSourceStateRead(t *testing.T) { func TestSourceStateReadExternal(t *testing.T) { httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := w.Write([]byte("data")) - require.NoError(t, err) + assert.NoError(t, err) })) defer httpServer.Close() for _, tc := range []struct { name string - root interface{} - expectedExternals map[RelPath]External + root any + expectedExternals map[RelPath][]*External }{ { name: "external_yaml", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ ".chezmoiexternal.yaml": chezmoitest.JoinLines( `file:`, ` type: "file"`, @@ -1332,17 +1535,20 @@ func TestSourceStateReadExternal(t *testing.T) { ), }, }, - expectedExternals: map[RelPath]External{ + expectedExternals: map[RelPath][]*External{ NewRelPath("file"): { - Type: "file", - URL: httpServer.URL + "/file", + { + Type: "file", + URL: httpServer.URL + "/file", + sourceAbsPath: NewAbsPath("/home/user/.local/share/chezmoi/.chezmoiexternal.yaml"), + }, }, }, }, { name: "external_toml", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ ".chezmoiexternal.toml": chezmoitest.JoinLines( `[file]`, ` type = "file"`, @@ -1350,17 +1556,20 @@ func TestSourceStateReadExternal(t *testing.T) { ), }, }, - expectedExternals: map[RelPath]External{ + expectedExternals: map[RelPath][]*External{ NewRelPath("file"): { - Type: "file", - URL: httpServer.URL + "/file", + { + Type: "file", + URL: httpServer.URL + "/file", + sourceAbsPath: NewAbsPath("/home/user/.local/share/chezmoi/.chezmoiexternal.toml"), + }, }, }, }, { name: "external_in_subdir", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi/dot_dir": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi/dot_dir": map[string]any{ ".chezmoiexternal.yaml": chezmoitest.JoinLines( `file:`, ` type: "file"`, @@ -1368,10 +1577,13 @@ func TestSourceStateReadExternal(t *testing.T) { ), }, }, - expectedExternals: map[RelPath]External{ + expectedExternals: map[RelPath][]*External{ NewRelPath(".dir/file"): { - Type: "file", - URL: httpServer.URL + "/file", + { + Type: "file", + URL: httpServer.URL + "/file", + sourceAbsPath: NewAbsPath("/home/user/.local/share/chezmoi/dot_dir/.chezmoiexternal.yaml"), + }, }, }, }, @@ -1387,37 +1599,68 @@ func TestSourceStateReadExternal(t *testing.T) { WithSourceDir(NewAbsPath("/home/user/.local/share/chezmoi")), WithSystem(system), ) - require.NoError(t, s.Read(ctx, nil)) + assert.NoError(t, s.Read(ctx, nil)) assert.Equal(t, tc.expectedExternals, s.externals) }) }) } } +func TestSourceStateReadScriptsConcurrent(t *testing.T) { + for _, tc := range []struct { + name string + root any + }{ + { + name: "with_ignore", + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ + ".chezmoiignore": ".chezmoiscripts/linux/**\n", + ".chezmoiscripts": map[string]any{ + "linux": manyScripts(1000), + "darwin": manyScripts(1000), + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fileSystem vfs.FS) { + ctx := context.Background() + system := NewRealSystem(fileSystem) + s := NewSourceState( + WithBaseSystem(system), + WithCacheDir(NewAbsPath("/home/user/.cache/chezmoi")), + WithDestDir(NewAbsPath("/home/user")), + WithSourceDir(NewAbsPath("/home/user/.local/share/chezmoi")), + WithSystem(system), + ) + + assert.NoError(t, s.Read(ctx, nil)) + }) + }) + } +} + func TestSourceStateReadExternalCache(t *testing.T) { buffer := &bytes.Buffer{} - tarWriterSystem := NewTARWriterSystem(buffer, tar.Header{}) - require.NoError(t, tarWriterSystem.WriteFile(NewAbsPath("file"), []byte("# contents of file\n"), 0o666)) - require.NoError(t, tarWriterSystem.Close()) + tarWriterSystem := NewTarWriterSystem(buffer, tar.Header{}) + assert.NoError(t, tarWriterSystem.WriteFile(NewAbsPath("file"), []byte("# contents of file\n"), 0o666)) + assert.NoError(t, tarWriterSystem.Close()) archiveData := buffer.Bytes() httpRequests := 0 httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { httpRequests++ _, err := w.Write(archiveData) - require.NoError(t, err) + assert.NoError(t, err) })) defer httpServer.Close() now := time.Now() - readOptions := &ReadOptions{ - TimeNow: func() time.Time { - return now - }, - } - chezmoitest.WithTestFS(t, map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ ".chezmoiexternal.yaml": chezmoitest.JoinLines( `.dir:`, ` type: "archive"`, @@ -1429,7 +1672,7 @@ func TestSourceStateReadExternalCache(t *testing.T) { ctx := context.Background() system := NewRealSystem(fileSystem) - readSourceState := func() { + readSourceState := func(refreshExternals RefreshExternals) { s := NewSourceState( WithBaseSystem(system), WithCacheDir(NewAbsPath("/home/user/.cache/chezmoi")), @@ -1437,33 +1680,49 @@ func TestSourceStateReadExternalCache(t *testing.T) { WithSourceDir(NewAbsPath("/home/user/.local/share/chezmoi")), WithSystem(system), ) - require.NoError(t, s.Read(ctx, readOptions)) - assert.Equal(t, map[RelPath]External{ + assert.NoError(t, s.Read(ctx, &ReadOptions{ + RefreshExternals: refreshExternals, + TimeNow: func() time.Time { + return now + }, + })) + assert.Equal(t, map[RelPath][]*External{ NewRelPath(".dir"): { - Type: "archive", - URL: httpServer.URL + "/archive.tar", - RefreshPeriod: 1 * time.Minute, + { + Type: "archive", + URL: httpServer.URL + "/archive.tar", + RefreshPeriod: Duration(1 * time.Minute), + sourceAbsPath: NewAbsPath("/home/user/.local/share/chezmoi/.chezmoiexternal.yaml"), + }, }, }, s.externals) } - readSourceState() + readSourceState(RefreshExternalsAuto) assert.Equal(t, 1, httpRequests) now = now.Add(10 * time.Second) - readSourceState() + readSourceState(RefreshExternalsAuto) assert.Equal(t, 1, httpRequests) now = now.Add(1 * time.Minute) - readSourceState() + readSourceState(RefreshExternalsAuto) assert.Equal(t, 2, httpRequests) + + now = now.Add(10 * time.Second) + readSourceState(RefreshExternalsAlways) + assert.Equal(t, 3, httpRequests) + + now = now.Add(5 * time.Minute) + readSourceState(RefreshExternalsNever) + assert.Equal(t, 3, httpRequests) }) } func TestSourceStateTargetRelPaths(t *testing.T) { for _, tc := range []struct { name string - root interface{} + root any expectedTargetRelPaths []RelPath }{ { @@ -1473,8 +1732,8 @@ func TestSourceStateTargetRelPaths(t *testing.T) { }, { name: "scripts", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + root: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ "run_before_1before": "", "run_before_2before": "", "run_before_3before": "", @@ -1508,62 +1767,197 @@ func TestSourceStateTargetRelPaths(t *testing.T) { WithSourceDir(NewAbsPath("/home/user/.local/share/chezmoi")), WithSystem(system), ) - require.NoError(t, s.Read(ctx, nil)) + assert.NoError(t, s.Read(ctx, nil)) assert.Equal(t, tc.expectedTargetRelPaths, s.TargetRelPaths()) }) }) } } -func TestWalkSourceDir(t *testing.T) { - sourceDirAbsPath := NewAbsPath("/home/user/.local/share/chezmoi") - root := map[string]interface{}{ - sourceDirAbsPath.String(): map[string]interface{}{ - ".chezmoi.toml.tmpl": "", - ".chezmoidata.json": "", - ".chezmoidata.toml": "", - ".chezmoidata.yaml": "", - ".chezmoiexternal.yaml": "", - ".chezmoiignore": "", - ".chezmoiremove": "", - ".chezmoitemplates": &vfst.Dir{Perm: 0o777}, - ".chezmoiversion": "", - "dot_file": "", +func TestTemplateOptionsParseDirectives(t *testing.T) { + for _, tc := range []struct { + name string + dataStr string + expected TemplateOptions + expectedDataStr string + }{ + { + name: "empty", }, + { + name: "unquoted", + dataStr: "chezmoi:template:left-delimiter=[[ right-delimiter=]]", + expected: TemplateOptions{ + LeftDelimiter: "[[", + RightDelimiter: "]]", + }, + }, + { + name: "quoted", + dataStr: `chezmoi:template:left-delimiter="# {{" right-delimiter="}}"`, + expected: TemplateOptions{ + LeftDelimiter: "# {{", + RightDelimiter: "}}", + }, + }, + { + name: "left_only", + dataStr: "chezmoi:template:left-delimiter=[[", + expected: TemplateOptions{ + LeftDelimiter: "[[", + }, + }, + { + name: "left_quoted_only", + dataStr: `chezmoi:template:left-delimiter="# [["`, + expected: TemplateOptions{ + LeftDelimiter: "# [[", + }, + }, + { + name: "right_quoted_only", + dataStr: `chezmoi:template:right-delimiter="]]"`, + expected: TemplateOptions{ + RightDelimiter: "]]", + }, + }, + { + name: "line_with_leading_data", + dataStr: "# chezmoi:template:left-delimiter=[[ right-delimiter=]]", + expected: TemplateOptions{ + LeftDelimiter: "[[", + RightDelimiter: "]]", + }, + }, + { + name: "line_before", + dataStr: chezmoitest.JoinLines( + "# before", + "# chezmoi:template:left-delimiter=[[ right-delimiter=]]", + ), + expected: TemplateOptions{ + LeftDelimiter: "[[", + RightDelimiter: "]]", + }, + expectedDataStr: chezmoitest.JoinLines( + "# before", + ), + }, + { + name: "line_after", + dataStr: chezmoitest.JoinLines( + "# chezmoi:template:left-delimiter=[[ right-delimiter=]]", + "# after", + ), + expected: TemplateOptions{ + LeftDelimiter: "[[", + RightDelimiter: "]]", + }, + expectedDataStr: chezmoitest.JoinLines( + "# after", + ), + }, + { + name: "line_before_and_after", + dataStr: chezmoitest.JoinLines( + "# before", + "# chezmoi:template:left-delimiter=[[ right-delimiter=]]", + "# after", + ), + expected: TemplateOptions{ + LeftDelimiter: "[[", + RightDelimiter: "]]", + }, + expectedDataStr: chezmoitest.JoinLines( + "# before", + "# after", + ), + }, + { + name: "multiple_lines", + dataStr: chezmoitest.JoinLines( + "# before", + "# chezmoi:template:left-delimiter=<<", + "# during", + "# chezmoi:template:left-delimiter=[[", + "# chezmoi:template:right-delimiter=]]", + "# after", + ), + expected: TemplateOptions{ + LeftDelimiter: "[[", + RightDelimiter: "]]", + }, + expectedDataStr: chezmoitest.JoinLines( + "# before", + "# during", + "# after", + ), + }, + { + name: "duplicate_directives", + dataStr: chezmoitest.JoinLines( + "# chezmoi:template:left-delimiter=<<", + "# chezmoi:template:left-delimiter=[[", + ), + expected: TemplateOptions{ + LeftDelimiter: "[[", + }, + }, + { + name: "missing_key", + dataStr: "chezmoi:template:missing-key=zero", + expected: TemplateOptions{ + Options: []string{"missingkey=zero"}, + }, + }, + { + name: "line_ending_crlf", + dataStr: "chezmoi:template:line-ending=crlf", + expected: TemplateOptions{ + LineEnding: "\r\n", + }, + }, + { + name: "line_endings_crlf", + dataStr: "chezmoi:template:line-endings=crlf", + expected: TemplateOptions{ + LineEnding: "\r\n", + }, + }, + { + name: "line_ending_quoted", + dataStr: `chezmoi:template:line-ending="\n"`, + expected: TemplateOptions{ + LineEnding: "\n", + }, + }, + { + name: "line_endings_quoted", + dataStr: `chezmoi:template:line-endings="\n"`, + expected: TemplateOptions{ + LineEnding: "\n", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var actual TemplateOptions + actualData := actual.parseAndRemoveDirectives([]byte(tc.dataStr)) + assert.Equal(t, tc.expected, actual) + assert.Equal(t, tc.expectedDataStr, string(actualData)) + }) } - expectedAbsPaths := []AbsPath{ - sourceDirAbsPath, - sourceDirAbsPath.JoinString(".chezmoidata.json"), - sourceDirAbsPath.JoinString(".chezmoidata.toml"), - sourceDirAbsPath.JoinString(".chezmoidata.yaml"), - sourceDirAbsPath.JoinString(".chezmoitemplates"), - sourceDirAbsPath.JoinString(".chezmoi.toml.tmpl"), - sourceDirAbsPath.JoinString(".chezmoiexternal.yaml"), - sourceDirAbsPath.JoinString(".chezmoiignore"), - sourceDirAbsPath.JoinString(".chezmoiremove"), - sourceDirAbsPath.JoinString(".chezmoiversion"), - sourceDirAbsPath.JoinString("dot_file"), - } - - var actualAbsPaths []AbsPath - chezmoitest.WithTestFS(t, root, func(fileSystem vfs.FS) { - system := NewRealSystem(fileSystem) - require.NoError(t, WalkSourceDir(system, sourceDirAbsPath, func(absPath AbsPath, fileInfo fs.FileInfo, err error) error { - if err != nil { - return err - } - actualAbsPaths = append(actualAbsPaths, absPath) - return nil - })) - }) - assert.Equal(t, expectedAbsPaths, actualAbsPaths) } -// applyAll updates targetDir in targetSystem to match s. -func (s *SourceState) applyAll(targetSystem, destSystem System, persistentState PersistentState, targetDir AbsPath, options ApplyOptions) error { +// applyAll updates targetDirAbsPath in targetSystem to match s. +func (s *SourceState) applyAll( + targetSystem, destSystem System, + persistentState PersistentState, + targetDirAbsPath AbsPath, + options ApplyOptions, +) error { for _, targetRelPath := range s.TargetRelPaths() { - switch err := s.Apply(targetSystem, destSystem, persistentState, targetDir, targetRelPath, options); { - case errors.Is(err, Skip): + switch err := s.Apply(targetSystem, destSystem, persistentState, targetDirAbsPath, targetRelPath, options); { + case errors.Is(err, fs.SkipDir): continue case err != nil: return err @@ -1576,7 +1970,7 @@ func (s *SourceState) applyAll(targetSystem, destSystem System, persistentState // without error. func requireEvaluateAll(t *testing.T, s *SourceState, destSystem System) { t.Helper() - require.NoError(t, s.root.ForEach(EmptyRelPath, func(targetRelPath RelPath, sourceStateEntry SourceStateEntry) error { + err := s.root.forEach(EmptyRelPath, func(targetRelPath RelPath, sourceStateEntry SourceStateEntry) error { if err := sourceStateEntry.Evaluate(); err != nil { return err } @@ -1586,14 +1980,15 @@ func requireEvaluateAll(t *testing.T, s *SourceState, destSystem System) { return err } return targetStateEntry.Evaluate() - })) + }) + assert.NoError(t, err) } func withEntries(sourceEntries map[RelPath]SourceStateEntry) SourceStateOption { return func(s *SourceState) { s.root = sourceStateEntryTreeNode{} for targetRelPath, sourceStateEntry := range sourceEntries { - s.root.Set(targetRelPath, sourceStateEntry) + s.root.set(targetRelPath, sourceStateEntry) } } } @@ -1604,21 +1999,37 @@ func withIgnore(ignore *patternSet) SourceStateOption { } } -func withMinVersion(minVersion semver.Version) SourceStateOption { +func withIgnoredRelPathStrs(relPathStrs ...string) SourceStateOption { + return func(s *SourceState) { + for _, relPathStr := range relPathStrs { + s.ignoredRelPaths.Add(NewRelPath(relPathStr)) + } + } +} + +func withRemove(remove *patternSet) SourceStateOption { return func(s *SourceState) { - s.minVersion = minVersion + s.remove = remove } } // withUserTemplateData adds template data. -func withUserTemplateData(templateData map[string]interface{}) SourceStateOption { +func withUserTemplateData(templateData map[string]any) SourceStateOption { return func(s *SourceState) { RecursiveMerge(s.userTemplateData, templateData) } } -func withTemplates(templates map[string]*template.Template) SourceStateOption { +func withTemplates(templates map[string]*Template) SourceStateOption { return func(s *SourceState) { s.templates = templates } } + +func manyScripts(amount int) map[string]any { + scripts := map[string]any{} + for i := 0; i < amount; i++ { + scripts[fmt.Sprintf("run_onchange_before_%d.sh", i)] = "" + } + return scripts +} diff --git a/internal/chezmoi/sourcestateentry.go b/internal/chezmoi/sourcestateentry.go index b4e4fe1181b..7de3b54c4bc 100644 --- a/internal/chezmoi/sourcestateentry.go +++ b/internal/chezmoi/sourcestateentry.go @@ -2,26 +2,51 @@ package chezmoi import ( "encoding/hex" - - "github.com/rs/zerolog" + "log/slog" "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) +// A SourceAttr contains attributes of the source. +type SourceAttr struct { + Condition ScriptCondition + Encrypted bool + External bool + Template bool +} + +// A SourceStateOrigin represents the origin of a source state. +type SourceStateOrigin interface { + Path() AbsPath + OriginString() string +} + +// A SourceStateOriginAbsPath is an absolute path. +type SourceStateOriginAbsPath AbsPath + // A SourceStateEntry represents the state of an entry in the source state. type SourceStateEntry interface { - zerolog.LogObjectMarshaler + slog.LogValuer Evaluate() error Order() ScriptOrder - Origin() string + Origin() SourceStateOrigin SourceRelPath() SourceRelPath TargetStateEntry(destSystem System, destDirAbsPath AbsPath) (TargetStateEntry, error) } +// A SourceStateCommand represents a command that should be run. +type SourceStateCommand struct { + cmd *lazyCommand + origin SourceStateOrigin + forceRefresh bool + refreshPeriod Duration + sourceAttr SourceAttr +} + // A SourceStateDir represents the state of a directory in the source state. type SourceStateDir struct { Attr DirAttr - origin string + origin SourceStateOrigin sourceRelPath SourceRelPath targetStateEntry TargetStateEntry } @@ -30,23 +55,73 @@ type SourceStateDir struct { type SourceStateFile struct { *lazyContents Attr FileAttr - origin string + origin SourceStateOrigin sourceRelPath SourceRelPath targetStateEntryFunc targetStateEntryFunc targetStateEntry TargetStateEntry targetStateEntryErr error } +// A SourceStateImplicitDir represents the state of a directory that is implicit +// in the source state, typically because it is a parent directory of an +// external. Implicit directories have no attributes and are considered +// equivalent to any other directory. +type SourceStateImplicitDir struct { + origin SourceStateOrigin + targetStateEntry TargetStateEntry +} + // A SourceStateRemove represents that an entry should be removed. type SourceStateRemove struct { + origin SourceStateOrigin + sourceRelPath SourceRelPath targetRelPath RelPath } -// A SourceStateRenameDir represents the renaming of a directory in the source -// state. -type SourceStateRenameDir struct { - oldSourceRelPath SourceRelPath - newSourceRelPath SourceRelPath +// A SourceStateOriginRemove is used for removes. The source of the remove is +// not currently tracked. The remove could come from an exact_ directory, a +// non-empty_ file with empty contents, or one of many patterns in many +// .chezmoiignore files. +// +// FIXME remove this when the sources of all removes are tracked. +type SourceStateOriginRemove struct{} + +// Evaluate evaluates s and returns any error. +func (s *SourceStateCommand) Evaluate() error { + return nil +} + +// LogValue implements log/slog.LogValuer.LogValue. +func (s *SourceStateCommand) LogValue() slog.Value { + return slog.GroupValue( + slog.Any("cmd", chezmoilog.OSExecCmdLogValuer{Cmd: s.cmd.Command()}), + slog.String("origin", s.origin.OriginString()), + ) +} + +// Order returns s's order. +func (s *SourceStateCommand) Order() ScriptOrder { + return ScriptOrderDuring +} + +// Origin returns s's origin. +func (s *SourceStateCommand) Origin() SourceStateOrigin { + return s.origin +} + +// SourceRelPath returns s's source relative path. +func (s *SourceStateCommand) SourceRelPath() SourceRelPath { + return emptySourceRelPath +} + +// TargetStateEntry returns s's target state entry. +func (s *SourceStateCommand) TargetStateEntry(destSystem System, destDirAbsPath AbsPath) (TargetStateEntry, error) { + return &TargetStateModifyDirWithCmd{ + cmd: s.cmd, + forceRefresh: s.forceRefresh, + refreshPeriod: s.refreshPeriod, + sourceAttr: s.sourceAttr, + }, nil } // Evaluate evaluates s and returns any error. @@ -54,11 +129,12 @@ func (s *SourceStateDir) Evaluate() error { return nil } -// MarshalZerologObject implements -// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. -func (s *SourceStateDir) MarshalZerologObject(e *zerolog.Event) { - e.Stringer("sourceRelPath", s.sourceRelPath) - e.Object("attr", s.Attr) +// LogValue implements log/slog.LogValuer.LogValue. +func (s *SourceStateDir) LogValue() slog.Value { + return slog.GroupValue( + chezmoilog.Stringer("sourceRelPath", s.sourceRelPath), + slog.Any("attr", s.Attr), + ) } // Order returns s's order. @@ -67,7 +143,7 @@ func (s *SourceStateDir) Order() ScriptOrder { } // Origin returns s's origin. -func (s *SourceStateDir) Origin() string { +func (s *SourceStateDir) Origin() SourceStateOrigin { return s.origin } @@ -87,22 +163,23 @@ func (s *SourceStateFile) Evaluate() error { return err } -// MarshalZerologObject implements -// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. -func (s *SourceStateFile) MarshalZerologObject(e *zerolog.Event) { - e.Stringer("sourceRelPath", s.sourceRelPath) - e.Interface("attr", s.Attr) +// LogValue implements log/slog.LogValuer.LogValue. +func (s *SourceStateFile) LogValue() slog.Value { + attrs := []slog.Attr{ + chezmoilog.Stringer("sourceRelPath", s.sourceRelPath), + slog.Any("attr", s.Attr), + } contents, contentsErr := s.Contents() - e.Bytes("contents", chezmoilog.FirstFewBytes(contents)) + attrs = append(attrs, chezmoilog.FirstFewBytes("contents", contents)) if contentsErr != nil { - e.Str("contentsErr", contentsErr.Error()) + attrs = append(attrs, slog.Any("contentsErr", contentsErr)) } - e.Err(contentsErr) contentsSHA256, contentsSHA256Err := s.ContentsSHA256() - e.Str("contentsSHA256", hex.EncodeToString(contentsSHA256)) + attrs = append(attrs, slog.String("contentsSHA256", hex.EncodeToString(contentsSHA256))) if contentsSHA256Err != nil { - e.Str("contentsSHA256Err", contentsSHA256Err.Error()) + attrs = append(attrs, slog.Any("contentsSHA256Err", contentsSHA256Err)) } + return slog.GroupValue(attrs...) } // Order returns s's order. @@ -111,7 +188,7 @@ func (s *SourceStateFile) Order() ScriptOrder { } // Origin returns s's origin. -func (s *SourceStateFile) Origin() string { +func (s *SourceStateFile) Origin() SourceStateOrigin { return s.origin } @@ -130,66 +207,83 @@ func (s *SourceStateFile) TargetStateEntry(destSystem System, destDirAbsPath Abs } // Evaluate evaluates s and returns any error. -func (s *SourceStateRemove) Evaluate() error { +func (s *SourceStateImplicitDir) Evaluate() error { return nil } -// MarshalZerologObject implements zerolog.LogObjectMarshaler. -func (s *SourceStateRemove) MarshalZerologObject(e *zerolog.Event) { - e.Stringer("targetRelPath", s.targetRelPath) +// LogValue implements log/slog.LogValuer.LogValue. +func (s *SourceStateImplicitDir) LogValue() slog.Value { + return slog.GroupValue() } // Order returns s's order. -func (s *SourceStateRemove) Order() ScriptOrder { +func (s *SourceStateImplicitDir) Order() ScriptOrder { return ScriptOrderDuring } // Origin returns s's origin. -func (s *SourceStateRemove) Origin() string { - return "" +func (s *SourceStateImplicitDir) Origin() SourceStateOrigin { + return s.origin } // SourceRelPath returns s's source relative path. -func (s *SourceStateRemove) SourceRelPath() SourceRelPath { - return SourceRelPath{} +func (s *SourceStateImplicitDir) SourceRelPath() SourceRelPath { + return emptySourceRelPath } // TargetStateEntry returns s's target state entry. -func (s *SourceStateRemove) TargetStateEntry(destSystem System, destDirAbsPath AbsPath) (TargetStateEntry, error) { - return &TargetStateRemove{}, nil +func (s *SourceStateImplicitDir) TargetStateEntry(destSystem System, destDirAbsPath AbsPath) (TargetStateEntry, error) { + return s.targetStateEntry, nil } // Evaluate evaluates s and returns any error. -func (s *SourceStateRenameDir) Evaluate() error { +func (s *SourceStateRemove) Evaluate() error { return nil } -// MarshalZerologObject implements -// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. -func (s *SourceStateRenameDir) MarshalZerologObject(e *zerolog.Event) { - e.Stringer("oldSourceRelPath", s.oldSourceRelPath) - e.Stringer("newSourceRelPath", s.newSourceRelPath) +// LogValue implements log/slog.LogValuer.LogValue. +func (s *SourceStateRemove) LogValue() slog.Value { + return slog.GroupValue( + chezmoilog.Stringer("targetRelPath", s.targetRelPath), + ) } // Order returns s's order. -func (s *SourceStateRenameDir) Order() ScriptOrder { - return ScriptOrderBefore +func (s *SourceStateRemove) Order() ScriptOrder { + return ScriptOrderDuring } // Origin returns s's origin. -func (s *SourceStateRenameDir) Origin() string { - return "" +func (s *SourceStateRemove) Origin() SourceStateOrigin { + return s.origin } // SourceRelPath returns s's source relative path. -func (s *SourceStateRenameDir) SourceRelPath() SourceRelPath { - return s.newSourceRelPath +func (s *SourceStateRemove) SourceRelPath() SourceRelPath { + return SourceRelPath{} } // TargetStateEntry returns s's target state entry. -func (s *SourceStateRenameDir) TargetStateEntry(destSystem System, destDirAbsPath AbsPath) (TargetStateEntry, error) { - return &targetStateRenameDir{ - oldRelPath: s.oldSourceRelPath.RelPath(), - newRelPath: s.newSourceRelPath.RelPath(), - }, nil +func (s *SourceStateRemove) TargetStateEntry(destSystem System, destDirAbsPath AbsPath) (TargetStateEntry, error) { + return &TargetStateRemove{}, nil +} + +// Path returns s's path. +func (s SourceStateOriginAbsPath) Path() AbsPath { + return AbsPath(s) +} + +// OriginString returns s's origin. +func (s SourceStateOriginAbsPath) OriginString() string { + return AbsPath(s).String() +} + +// Path returns s's path. +func (s SourceStateOriginRemove) Path() AbsPath { + return EmptyAbsPath +} + +// OriginString returns s's origin. +func (s SourceStateOriginRemove) OriginString() string { + return "remove" } diff --git a/internal/chezmoi/sourcestatetreenode.go b/internal/chezmoi/sourcestatetreenode.go index 8e340a4f5e4..f3714b510b8 100644 --- a/internal/chezmoi/sourcestatetreenode.go +++ b/internal/chezmoi/sourcestatetreenode.go @@ -1,9 +1,12 @@ package chezmoi import ( + "errors" "fmt" "io/fs" "sort" + + "github.com/twpayne/chezmoi/v2/internal/chezmoimaps" ) // A sourceStateEntryTreeNode is a node in a tree of SourceStateEntries. @@ -17,35 +20,37 @@ func newSourceStateTreeNode() *sourceStateEntryTreeNode { return &sourceStateEntryTreeNode{} } -// Get returns the SourceStateEntry at relPath. -func (n *sourceStateEntryTreeNode) Get(relPath RelPath) SourceStateEntry { - node := n.GetNode(relPath) - if node == nil { +// get returns the SourceStateEntry at relPath. +func (n *sourceStateEntryTreeNode) get(relPath RelPath) SourceStateEntry { + nodes := n.getNodes(relPath) + if nodes == nil { return nil } - return node.sourceStateEntry + return nodes[len(nodes)-1].sourceStateEntry } -// GetNode returns the SourceStateTreeNode at relPath. -func (n *sourceStateEntryTreeNode) GetNode(targetRelPath RelPath) *sourceStateEntryTreeNode { +// getNodes returns the sourceStateEntryTreeNodes to reach targetRelPath. +func (n *sourceStateEntryTreeNode) getNodes(targetRelPath RelPath) []*sourceStateEntryTreeNode { if targetRelPath.Empty() { - return n + return []*sourceStateEntryTreeNode{n} } - node := n - for _, childRelPath := range targetRelPath.SplitAll() { - var ok bool - node, ok = node.children[childRelPath] - if !ok { + targetRelPathComponents := targetRelPath.SplitAll() + nodes := make([]*sourceStateEntryTreeNode, 0, len(targetRelPathComponents)) + nodes = append(nodes, n) + for _, childRelPath := range targetRelPathComponents { + if childNode, ok := nodes[len(nodes)-1].children[childRelPath]; ok { + nodes = append(nodes, childNode) + } else { return nil } } - return node + return nodes } -// ForEach calls f for each SourceStateEntry in the tree. -func (n *sourceStateEntryTreeNode) ForEach(targetRelPath RelPath, f func(RelPath, SourceStateEntry) error) error { - return n.ForEachNode(targetRelPath, func(targetRelPath RelPath, node *sourceStateEntryTreeNode) error { +// forEach calls f for each SourceStateEntry in the tree. +func (n *sourceStateEntryTreeNode) forEach(targetRelPath RelPath, f func(RelPath, SourceStateEntry) error) error { + return n.forEachNode(targetRelPath, func(targetRelPath RelPath, node *sourceStateEntryTreeNode) error { if node.sourceStateEntry == nil { return nil } @@ -53,23 +58,20 @@ func (n *sourceStateEntryTreeNode) ForEach(targetRelPath RelPath, f func(RelPath }) } -// ForEachNode calls f for each node in the tree. -func (n *sourceStateEntryTreeNode) ForEachNode( - targetRelPath RelPath, f func(RelPath, *sourceStateEntryTreeNode) error, -) error { - if err := f(targetRelPath, n); err != nil { +// forEachNode calls f for each node in the tree. +func (n *sourceStateEntryTreeNode) forEachNode(targetRelPath RelPath, f func(RelPath, *sourceStateEntryTreeNode) error) error { + switch err := f(targetRelPath, n); { + case errors.Is(err, fs.SkipDir): + return nil + case err != nil: return err } - childrenByRelPath := make(RelPaths, 0, len(n.children)) - for childRelPath := range n.children { - childrenByRelPath = append(childrenByRelPath, childRelPath) - } - + childrenByRelPath := RelPaths(chezmoimaps.Keys(n.children)) sort.Sort(childrenByRelPath) for _, childRelPath := range childrenByRelPath { child := n.children[childRelPath] - if err := child.ForEachNode(targetRelPath.Join(childRelPath), f); err != nil { + if err := child.forEachNode(targetRelPath.Join(childRelPath), f); err != nil { return err } } @@ -77,20 +79,22 @@ func (n *sourceStateEntryTreeNode) ForEachNode( return nil } -// Map returns a map of relPaths to SourceStateEntries. -func (n *sourceStateEntryTreeNode) Map() map[RelPath]SourceStateEntry { +// getMap returns a map of relPaths to SourceStateEntries. +func (n *sourceStateEntryTreeNode) getMap() map[RelPath]SourceStateEntry { m := make(map[RelPath]SourceStateEntry) - _ = n.ForEach(EmptyRelPath, func(relPath RelPath, sourceStateEntry SourceStateEntry) error { + _ = n.forEach(EmptyRelPath, func(relPath RelPath, sourceStateEntry SourceStateEntry) error { m[relPath] = sourceStateEntry return nil }) return m } -// MkdirAll creates SourceStateDirs for all components of targetRelPath if they +// mkdirAll creates SourceStateDirs for all components of targetRelPath if they // do not already exist and returns the SourceStateDir of relPath. -func (n *sourceStateEntryTreeNode) MkdirAll( - targetRelPath RelPath, origin string, umask fs.FileMode, +func (n *sourceStateEntryTreeNode) mkdirAll( + targetRelPath RelPath, + origin SourceStateOrigin, + umask fs.FileMode, ) (*SourceStateDir, error) { if targetRelPath == EmptyRelPath { return nil, nil @@ -140,8 +144,8 @@ func (n *sourceStateEntryTreeNode) MkdirAll( return sourceStateDir, nil } -// Set sets the SourceStateEntry at relPath to sourceStateEntry. -func (n *sourceStateEntryTreeNode) Set(targetRelPath RelPath, sourceStateEntry SourceStateEntry) { +// set sets the SourceStateEntry at relPath to sourceStateEntry. +func (n *sourceStateEntryTreeNode) set(targetRelPath RelPath, sourceStateEntry SourceStateEntry) { if targetRelPath.Empty() { n.sourceStateEntry = sourceStateEntry return diff --git a/internal/chezmoi/sourcestatetreenode_test.go b/internal/chezmoi/sourcestatetreenode_test.go index 94bb092b90a..3edcd5d0588 100644 --- a/internal/chezmoi/sourcestatetreenode_test.go +++ b/internal/chezmoi/sourcestatetreenode_test.go @@ -4,14 +4,14 @@ import ( "errors" "testing" - "github.com/stretchr/testify/assert" + "github.com/alecthomas/assert/v2" ) func TestSourceStateEntryTreeNodeEmpty(t *testing.T) { n := newSourceStateTreeNode() - assert.Equal(t, nil, n.Get(EmptyRelPath)) - assert.Equal(t, n, n.GetNode(EmptyRelPath)) - assert.NoError(t, n.ForEach(EmptyRelPath, func(RelPath, SourceStateEntry) error { + assert.Equal(t, nil, n.get(EmptyRelPath)) + assert.Equal(t, []*sourceStateEntryTreeNode{n}, n.getNodes(EmptyRelPath)) + assert.NoError(t, n.forEach(EmptyRelPath, func(RelPath, SourceStateEntry) error { return errors.New("should not be called") })) } @@ -19,13 +19,14 @@ func TestSourceStateEntryTreeNodeEmpty(t *testing.T) { func TestSourceStateEntryTreeNodeSingle(t *testing.T) { n := newSourceStateTreeNode() sourceStateFile := &SourceStateFile{} - n.Set(NewRelPath("file"), sourceStateFile) - assert.Equal(t, sourceStateFile, n.Get(NewRelPath("file"))) - assert.NoError(t, n.ForEach(EmptyRelPath, func(targetRelPath RelPath, sourceStateEntry SourceStateEntry) error { + n.set(NewRelPath("file"), sourceStateFile) + assert.Equal(t, sourceStateFile, n.get(NewRelPath("file")).(*SourceStateFile)) + err := n.forEach(EmptyRelPath, func(targetRelPath RelPath, sourceStateEntry SourceStateEntry) error { assert.Equal(t, NewRelPath("file"), targetRelPath) - assert.Equal(t, sourceStateFile, sourceStateEntry) + assert.Equal(t, sourceStateFile, sourceStateEntry.(*SourceStateFile)) return nil - })) + }) + assert.NoError(t, err) } func TestSourceStateEntryTreeNodeMultiple(t *testing.T) { @@ -39,15 +40,16 @@ func TestSourceStateEntryTreeNodeMultiple(t *testing.T) { } n := newSourceStateTreeNode() for targetRelPath, sourceStateEntry := range entries { - n.Set(targetRelPath, sourceStateEntry) + n.set(targetRelPath, sourceStateEntry) } var targetRelPaths []RelPath - assert.NoError(t, n.ForEach(EmptyRelPath, func(targetRelPath RelPath, sourceStateEntry SourceStateEntry) error { + err := n.forEach(EmptyRelPath, func(targetRelPath RelPath, sourceStateEntry SourceStateEntry) error { assert.Equal(t, entries[targetRelPath], sourceStateEntry) targetRelPaths = append(targetRelPaths, targetRelPath) return nil - })) + }) + assert.NoError(t, err) assert.Equal(t, []RelPath{ NewRelPath("a_file"), NewRelPath("b_file"), @@ -57,5 +59,5 @@ func TestSourceStateEntryTreeNodeMultiple(t *testing.T) { NewRelPath("dir/b_file"), }, targetRelPaths) - assert.Equal(t, entries, n.Map()) + assert.Equal(t, entries, n.getMap()) } diff --git a/internal/chezmoi/stringset.go b/internal/chezmoi/stringset.go deleted file mode 100644 index 9e43f9f7cb3..00000000000 --- a/internal/chezmoi/stringset.go +++ /dev/null @@ -1,38 +0,0 @@ -package chezmoi - -// A stringSet is a set of strings. -type stringSet map[string]struct{} - -// 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{}{} - } -} - -// contains returns true if s contains element. -func (s stringSet) contains(element string) bool { - _, ok := s[element] - return ok -} - -// 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 -} - -// remove removes an element from s. -func (s stringSet) remove(element string) { - delete(s, element) -} diff --git a/internal/chezmoi/system.go b/internal/chezmoi/system.go index 46b8365b4db..c01aa5c998c 100644 --- a/internal/chezmoi/system.go +++ b/internal/chezmoi/system.go @@ -1,53 +1,59 @@ package chezmoi import ( + "context" "errors" "io/fs" "os/exec" "sort" + "strings" + "time" - vfs "github.com/twpayne/go-vfs/v4" + vfs "github.com/twpayne/go-vfs/v5" + "golang.org/x/sync/errgroup" ) -// A System reads from and writes to a filesystem, executes idempotent commands, -// runs scripts, and persists state. -type System interface { +type RunScriptOptions struct { + Interpreter *Interpreter + Condition ScriptCondition + SourceRelPath SourceRelPath +} + +// A System reads from and writes to a filesystem, runs scripts, and persists +// state. +type System interface { //nolint:interfacebloat Chmod(name AbsPath, mode fs.FileMode) error + Chtimes(name AbsPath, atime, mtime time.Time) error Glob(pattern string) ([]string, error) - IdempotentCmdCombinedOutput(cmd *exec.Cmd) ([]byte, error) - IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) - Link(oldname, newname AbsPath) error + Link(oldName, newName AbsPath) error Lstat(filename AbsPath) (fs.FileInfo, error) Mkdir(name AbsPath, perm fs.FileMode) error RawPath(absPath AbsPath) (AbsPath, error) ReadDir(name AbsPath) ([]fs.DirEntry, error) ReadFile(name AbsPath) ([]byte, error) Readlink(name AbsPath) (string, error) + Remove(name AbsPath) error RemoveAll(name AbsPath) error - Rename(oldpath, newpath AbsPath) error + Rename(oldPath, newPath AbsPath) error RunCmd(cmd *exec.Cmd) error - RunIdempotentCmd(cmd *exec.Cmd) error - RunScript(scriptname RelPath, dir AbsPath, data []byte, interpreter *Interpreter) error + RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) error Stat(name AbsPath) (fs.FileInfo, error) UnderlyingFS() vfs.FS WriteFile(filename AbsPath, data []byte, perm fs.FileMode) error - WriteSymlink(oldname string, newname AbsPath) 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) IdempotentCmdCombinedOutput(cmd *exec.Cmd) ([]byte, error) { return nil, nil } -func (emptySystemMixin) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { return nil, nil } -func (emptySystemMixin) Lstat(name AbsPath) (fs.FileInfo, error) { return nil, fs.ErrNotExist } -func (emptySystemMixin) RawPath(path AbsPath) (AbsPath, error) { return path, nil } -func (emptySystemMixin) ReadDir(name AbsPath) ([]fs.DirEntry, error) { return nil, fs.ErrNotExist } -func (emptySystemMixin) ReadFile(name AbsPath) ([]byte, error) { return nil, fs.ErrNotExist } -func (emptySystemMixin) Readlink(name AbsPath) (string, error) { return "", fs.ErrNotExist } -func (emptySystemMixin) RunIdempotentCmd(cmd *exec.Cmd) error { return nil } -func (emptySystemMixin) Stat(name AbsPath) (fs.FileInfo, error) { return nil, fs.ErrNotExist } -func (emptySystemMixin) UnderlyingFS() vfs.FS { return nil } +func (emptySystemMixin) Glob(pattern string) ([]string, error) { return nil, nil } +func (emptySystemMixin) Lstat(name AbsPath) (fs.FileInfo, error) { return nil, fs.ErrNotExist } +func (emptySystemMixin) RawPath(path AbsPath) (AbsPath, error) { return path, nil } +func (emptySystemMixin) ReadDir(name AbsPath) ([]fs.DirEntry, error) { return nil, fs.ErrNotExist } +func (emptySystemMixin) ReadFile(name AbsPath) ([]byte, error) { return nil, fs.ErrNotExist } +func (emptySystemMixin) Readlink(name AbsPath) (string, error) { return "", fs.ErrNotExist } +func (emptySystemMixin) Stat(name AbsPath) (fs.FileInfo, error) { return nil, fs.ErrNotExist } +func (emptySystemMixin) UnderlyingFS() vfs.FS { return nil } // A noUpdateSystemMixin panics on any update. type noUpdateSystemMixin struct{} @@ -56,7 +62,11 @@ func (noUpdateSystemMixin) Chmod(name AbsPath, perm fs.FileMode) error { panic("update to no update system") } -func (noUpdateSystemMixin) Link(oldname, newname AbsPath) error { +func (noUpdateSystemMixin) Chtimes(name AbsPath, atime, mtime time.Time) error { + panic("update to no update system") +} + +func (noUpdateSystemMixin) Link(oldName, newName AbsPath) error { panic("update to no update system") } @@ -64,11 +74,15 @@ func (noUpdateSystemMixin) Mkdir(name AbsPath, perm fs.FileMode) error { panic("update to no update system") } +func (noUpdateSystemMixin) Remove(name AbsPath) error { + panic("update to no update system") +} + func (noUpdateSystemMixin) RemoveAll(name AbsPath) error { panic("update to no update system") } -func (noUpdateSystemMixin) Rename(oldpath, newpath AbsPath) error { +func (noUpdateSystemMixin) Rename(oldPath, newPath AbsPath) error { panic("update to no update system") } @@ -76,7 +90,7 @@ func (noUpdateSystemMixin) RunCmd(cmd *exec.Cmd) error { panic("update to no update system") } -func (noUpdateSystemMixin) RunScript(scriptname RelPath, dir AbsPath, data []byte, interpreter *Interpreter) error { +func (noUpdateSystemMixin) RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) error { panic("update to no update system") } @@ -84,7 +98,7 @@ func (noUpdateSystemMixin) WriteFile(filename AbsPath, data []byte, perm fs.File panic("update to no update system") } -func (noUpdateSystemMixin) WriteSymlink(oldname string, newname AbsPath) error { +func (noUpdateSystemMixin) WriteSymlink(oldName string, newName AbsPath) error { panic("update to no update system") } @@ -134,8 +148,8 @@ func MkdirAll(system System, absPath AbsPath, perm fs.FileMode) error { // A WalkFunc is called for every entry in a directory. type WalkFunc func(absPath AbsPath, fileInfo fs.FileInfo, err error) error -// Walk walks rootAbsPath in system, alling walkFunc for each file or directory in -// the tree, including rootAbsPath. +// Walk walks rootAbsPath in system, calling walkFunc for each file or directory +// in the tree, including rootAbsPath. // // Walk does not follow symlinks. func Walk(system System, rootAbsPath AbsPath, walkFunc WalkFunc) error { @@ -145,9 +159,9 @@ func Walk(system System, rootAbsPath AbsPath, walkFunc WalkFunc) error { return vfs.Walk(system.UnderlyingFS(), rootAbsPath.String(), outerWalkFunc) } -// A WalkSourceDirFunc is a function called for every entry in a source -// directory. -type WalkSourceDirFunc func(absPath AbsPath, fileInfo fs.FileInfo, err error) error +// A concurrentWalkSourceDirFunc is a function called concurrently for every +// entry in a source directory. +type concurrentWalkSourceDirFunc func(ctx context.Context, absPath AbsPath, fileInfo fs.FileInfo, err error) error // WalkSourceDir walks the source directory rooted at sourceDirAbsPath in // system, calling walkFunc for each file or directory in the tree, including @@ -159,15 +173,15 @@ type WalkSourceDirFunc func(absPath AbsPath, fileInfo fs.FileInfo, err error) er // Directory entries .chezmoidata. and .chezmoitemplates are visited // before all other entries. All other entries are visited in alphabetical // order. -func WalkSourceDir(system System, sourceDirAbsPath AbsPath, walkFunc WalkSourceDirFunc) error { +func WalkSourceDir(system System, sourceDirAbsPath AbsPath, walkFunc WalkFunc) error { fileInfo, err := system.Stat(sourceDirAbsPath) if err != nil { err = walkFunc(sourceDirAbsPath, nil, err) } else { err = walkSourceDir(system, sourceDirAbsPath, fileInfo, walkFunc) - } - if errors.Is(err, fs.SkipDir) { - return nil + if errors.Is(err, fs.SkipDir) { + err = nil + } } return err } @@ -176,14 +190,15 @@ func WalkSourceDir(system System, sourceDirAbsPath AbsPath, walkFunc WalkSourceD // source directory. More negative values are visited first. Entries with the // same order are visited alphabetically. The default order is zero. var sourceDirEntryOrder = map[string]int{ - ".chezmoidata.json": -2, - ".chezmoidata.toml": -2, - ".chezmoidata.yaml": -2, - ".chezmoitemplates": -1, + VersionName: -3, + dataName + ".json": -2, + dataName + ".toml": -2, + dataName + ".yaml": -2, + TemplatesDirName: -1, } // walkSourceDir is a helper function for WalkSourceDir. -func walkSourceDir(system System, name AbsPath, fileInfo fs.FileInfo, walkFunc WalkSourceDirFunc) error { +func walkSourceDir(system System, name AbsPath, fileInfo fs.FileInfo, walkFunc WalkFunc) error { switch err := walkFunc(name, fileInfo, nil); { case fileInfo.IsDir() && errors.Is(err, fs.SkipDir): return nil @@ -201,20 +216,7 @@ func walkSourceDir(system System, name AbsPath, fileInfo fs.FileInfo, walkFunc W } } - sort.Slice(dirEntries, func(i, j int) bool { - nameI := dirEntries[i].Name() - nameJ := dirEntries[j].Name() - orderI := sourceDirEntryOrder[nameI] - orderJ := sourceDirEntryOrder[nameJ] - switch { - case orderI < orderJ: - return true - case orderI == orderJ: - return nameI < nameJ - default: - return false - } - }) + sortSourceDirEntries(dirEntries) for _, dirEntry := range dirEntries { fileInfo, err := dirEntry.Info() @@ -225,12 +227,82 @@ func walkSourceDir(system System, name AbsPath, fileInfo fs.FileInfo, walkFunc W } } if err := walkSourceDir(system, name.JoinString(dirEntry.Name()), fileInfo, walkFunc); err != nil { - if errors.Is(err, fs.SkipDir) { - break + if !errors.Is(err, fs.SkipDir) { + return err } - return err } } return nil } + +func concurrentWalkSourceDir( + ctx context.Context, + system System, + dirAbsPath AbsPath, + walkFunc concurrentWalkSourceDirFunc, +) error { + dirEntries, err := system.ReadDir(dirAbsPath) + if err != nil { + return walkFunc(ctx, dirAbsPath, nil, err) + } + sortSourceDirEntries(dirEntries) + + // Walk all control plane entries in order. + visitDirEntry := func(dirEntry fs.DirEntry) error { + absPath := dirAbsPath.Join(NewRelPath(dirEntry.Name())) + fileInfo, err := dirEntry.Info() + if err != nil { + return walkFunc(ctx, absPath, nil, err) + } + switch err := walkFunc(ctx, absPath, fileInfo, nil); { + case fileInfo.IsDir() && errors.Is(err, fs.SkipDir): + return nil + case err != nil: + return err + case fileInfo.IsDir(): + return concurrentWalkSourceDir(ctx, system, absPath, walkFunc) + default: + return nil + } + } + i := 0 + for ; i < len(dirEntries); i++ { + dirEntry := dirEntries[i] + if !strings.HasPrefix(dirEntry.Name(), ".") { + break + } + if err := visitDirEntry(dirEntry); err != nil { + return err + } + } + + // Walk all remaining entries concurrently. + visitDirEntryFunc := func(dirEntry fs.DirEntry) func() error { + return func() error { + return visitDirEntry(dirEntry) + } + } + group, ctx := errgroup.WithContext(ctx) + for _, dirEntry := range dirEntries[i:] { + group.Go(visitDirEntryFunc(dirEntry)) + } + return group.Wait() +} + +func sortSourceDirEntries(dirEntries []fs.DirEntry) { + sort.Slice(dirEntries, func(i, j int) bool { + nameI := dirEntries[i].Name() + nameJ := dirEntries[j].Name() + orderI := sourceDirEntryOrder[nameI] + orderJ := sourceDirEntryOrder[nameJ] + switch { + case orderI < orderJ: + return true + case orderI == orderJ: + return nameI < nameJ + default: + return false + } + }) +} diff --git a/internal/chezmoi/system_test.go b/internal/chezmoi/system_test.go new file mode 100644 index 00000000000..d48dba063c9 --- /dev/null +++ b/internal/chezmoi/system_test.go @@ -0,0 +1,91 @@ +package chezmoi + +import ( + "context" + "io/fs" + "sort" + "sync" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" + + "github.com/twpayne/chezmoi/v2/internal/chezmoitest" +) + +func TestConcurrentWalkSourceDir(t *testing.T) { + sourceDirAbsPath := NewAbsPath("/home/user/.local/share/chezmoi") + root := map[string]any{ + sourceDirAbsPath.String(): map[string]any{ + ".chezmoiversion": "# contents of .chezmoiversion\n", + "dot_dir/file": "# contents of .dir/file\n", + }, + } + expectedSourceAbsPaths := AbsPaths{ + sourceDirAbsPath.JoinString(".chezmoiversion"), + sourceDirAbsPath.JoinString("dot_dir"), + sourceDirAbsPath.JoinString("dot_dir/file"), + } + + var actualSourceAbsPaths AbsPaths + chezmoitest.WithTestFS(t, root, func(fileSystem vfs.FS) { + ctx := context.Background() + system := NewRealSystem(fileSystem) + var mutex sync.Mutex + walkFunc := func(ctx context.Context, sourceAbsPath AbsPath, fileInfo fs.FileInfo, err error) error { + mutex.Lock() + actualSourceAbsPaths = append(actualSourceAbsPaths, sourceAbsPath) + mutex.Unlock() + return nil + } + assert.NoError(t, concurrentWalkSourceDir(ctx, system, sourceDirAbsPath, walkFunc)) + }) + sort.Sort(actualSourceAbsPaths) + assert.Equal(t, expectedSourceAbsPaths, actualSourceAbsPaths) +} + +func TestWalkSourceDir(t *testing.T) { + sourceDirAbsPath := NewAbsPath("/home/user/.local/share/chezmoi") + root := map[string]any{ + sourceDirAbsPath.String(): map[string]any{ + ".chezmoi.toml.tmpl": "", + ".chezmoidata.json": "", + ".chezmoidata.toml": "", + ".chezmoidata.yaml": "", + ".chezmoiexternal.yaml": "", + ".chezmoiignore": "", + ".chezmoiremove": "", + ".chezmoitemplates": &vfst.Dir{Perm: fs.ModePerm}, + ".chezmoiversion": "", + "dot_file": "", + }, + } + expectedSourceDirAbsPaths := []AbsPath{ + sourceDirAbsPath, + sourceDirAbsPath.JoinString(".chezmoiversion"), + sourceDirAbsPath.JoinString(".chezmoidata.json"), + sourceDirAbsPath.JoinString(".chezmoidata.toml"), + sourceDirAbsPath.JoinString(".chezmoidata.yaml"), + sourceDirAbsPath.JoinString(".chezmoitemplates"), + sourceDirAbsPath.JoinString(".chezmoi.toml.tmpl"), + sourceDirAbsPath.JoinString(".chezmoiexternal.yaml"), + sourceDirAbsPath.JoinString(".chezmoiignore"), + sourceDirAbsPath.JoinString(".chezmoiremove"), + sourceDirAbsPath.JoinString("dot_file"), + } + + var actualSourceDirAbsPaths []AbsPath + chezmoitest.WithTestFS(t, root, func(fileSystem vfs.FS) { + system := NewRealSystem(fileSystem) + err := WalkSourceDir(system, sourceDirAbsPath, func(absPath AbsPath, fileInfo fs.FileInfo, err error) error { + if err != nil { + return err + } + actualSourceDirAbsPaths = append(actualSourceDirAbsPaths, absPath) + return nil + }) + assert.NoError(t, err) + }) + assert.Equal(t, expectedSourceDirAbsPaths, actualSourceDirAbsPaths) +} diff --git a/internal/chezmoi/targetstateentry.go b/internal/chezmoi/targetstateentry.go index 54330acebdb..27779bec00b 100644 --- a/internal/chezmoi/targetstateentry.go +++ b/internal/chezmoi/targetstateentry.go @@ -3,6 +3,7 @@ package chezmoi import ( "bytes" "encoding/hex" + "fmt" "io/fs" "runtime" "time" @@ -10,23 +11,39 @@ import ( // A TargetStateEntry represents the state of an entry in the target state. type TargetStateEntry interface { - Apply(system System, persistentState PersistentState, actualStateEntry ActualStateEntry) (bool, error) + Apply( + system System, + persistentState PersistentState, + actualStateEntry ActualStateEntry, + ) (bool, error) EntryState(umask fs.FileMode) (*EntryState, error) Evaluate() error SkipApply(persistentState PersistentState, targetAbsPath AbsPath) (bool, error) + SourceAttr() SourceAttr +} + +// A TargetStateModifyDirWithCmd represents running a command that modifies +// a directory. +type TargetStateModifyDirWithCmd struct { + cmd *lazyCommand + forceRefresh bool + refreshPeriod Duration + sourceAttr SourceAttr } // A TargetStateDir represents the state of a directory in the target state. type TargetStateDir struct { - perm fs.FileMode + perm fs.FileMode + sourceAttr SourceAttr } // A TargetStateFile represents the state of a file in the target state. type TargetStateFile struct { *lazyContents - empty bool - overwrite bool - perm fs.FileMode + empty bool + overwrite bool + perm fs.FileMode + sourceAttr SourceAttr } // A TargetStateRemove represents the absence of an entry in the target state. @@ -35,32 +52,107 @@ type TargetStateRemove struct{} // A TargetStateScript represents the state of a script. type TargetStateScript struct { *lazyContents - name RelPath - interpreter *Interpreter - condition ScriptCondition + name RelPath + interpreter *Interpreter + condition ScriptCondition + sourceAttr SourceAttr + sourceRelPath SourceRelPath } // A TargetStateSymlink represents the state of a symlink in the target state. type TargetStateSymlink struct { *lazyLinkname + sourceAttr SourceAttr } -// A targetStateRenameDir represents the renaming of a directory in the target -// state. -type targetStateRenameDir struct { - oldRelPath RelPath - newRelPath RelPath +// A modifyDirWithCmdState records the state of a directory modified by a +// command. +type modifyDirWithCmdState struct { + Name AbsPath `json:"name" yaml:"name"` + RunAt time.Time `json:"runAt" yaml:"runAt"` } // A scriptState records the state of a script that has been run. type scriptState struct { - Name RelPath `json:"name" toml:"name" yaml:"name"` - RunAt time.Time `json:"runAt" toml:"runAt" yaml:"runAt"` + Name RelPath `json:"name" yaml:"name"` + RunAt time.Time `json:"runAt" yaml:"runAt"` +} + +// Apply updates actualStateEntry to match t. +func (t *TargetStateModifyDirWithCmd) Apply( + system System, + persistentState PersistentState, + actualStateEntry ActualStateEntry, +) (bool, error) { + if _, ok := actualStateEntry.(*ActualStateDir); !ok { + if err := actualStateEntry.Remove(system); err != nil { + return false, err + } + } + + runAt := time.Now().UTC() + if err := system.RunCmd(t.cmd.Command()); err != nil { + return false, fmt.Errorf("%s: %w", actualStateEntry.Path(), err) + } + + modifyDirWithCmdStateKey := []byte(actualStateEntry.Path().String()) + if err := PersistentStateSet( + persistentState, GitRepoExternalStateBucket, modifyDirWithCmdStateKey, &modifyDirWithCmdState{ + Name: actualStateEntry.Path(), + RunAt: runAt, + }); err != nil { + return false, err + } + + return true, nil +} + +// EntryState returns t's entry state. +func (t *TargetStateModifyDirWithCmd) EntryState(umask fs.FileMode) (*EntryState, error) { + return &EntryState{ + Type: EntryStateTypeDir, + Mode: fs.ModeDir | fs.ModePerm&^umask, + }, nil +} + +// Evaluate evaluates t. +func (t *TargetStateModifyDirWithCmd) Evaluate() error { + return nil +} + +// SkipApply implements TargetStateEntry.SkipApply. +func (t *TargetStateModifyDirWithCmd) SkipApply(persistentState PersistentState, targetAbsPath AbsPath) (bool, error) { + if t.forceRefresh { + return false, nil + } + modifyDirWithCmdKey := []byte(targetAbsPath.String()) + switch modifyDirWithCmdStateBytes, err := persistentState.Get(GitRepoExternalStateBucket, modifyDirWithCmdKey); { + case err != nil: + return false, err + case modifyDirWithCmdStateBytes == nil: + return false, nil + default: + var modifyDirWithCmdState modifyDirWithCmdState + if err := stateFormat.Unmarshal(modifyDirWithCmdStateBytes, &modifyDirWithCmdState); err != nil { + return false, err + } + if t.refreshPeriod == 0 { + return true, nil + } + return time.Since(modifyDirWithCmdState.RunAt) < time.Duration(t.refreshPeriod), nil + } +} + +// SourceAttr implements TargetStateEntry.SourceAttr. +func (t *TargetStateModifyDirWithCmd) SourceAttr() SourceAttr { + return t.sourceAttr } // Apply updates actualStateEntry to match t. It does not recurse. func (t *TargetStateDir) Apply( - system System, persistentState PersistentState, actualStateEntry ActualStateEntry, + system System, + persistentState PersistentState, + actualStateEntry ActualStateEntry, ) (bool, error) { if actualStateDir, ok := actualStateEntry.(*ActualStateDir); ok { if runtime.GOOS == "windows" || actualStateDir.perm == t.perm { @@ -92,9 +184,16 @@ func (t *TargetStateDir) SkipApply(persistentState PersistentState, targetAbsPat return false, nil } +// SourceAttr implements TargetStateEntry.SourceAttr. +func (t *TargetStateDir) SourceAttr() SourceAttr { + return t.sourceAttr +} + // Apply updates actualStateEntry to match t. func (t *TargetStateFile) Apply( - system System, persistentState PersistentState, actualStateEntry ActualStateEntry, + system System, + persistentState PersistentState, + actualStateEntry ActualStateEntry, ) (bool, error) { contents, err := t.Contents() if err != nil { @@ -165,14 +264,21 @@ func (t *TargetStateFile) Perm(umask fs.FileMode) fs.FileMode { return t.perm &^ umask } -// SkipApply implements TargetState.SkipApply. +// SkipApply implements TargetStateEntry.SkipApply. func (t *TargetStateFile) SkipApply(persistentState PersistentState, targetAbsPath AbsPath) (bool, error) { return false, nil } +// SourceAttr implements TargetStateEntry.SourceAttr. +func (t *TargetStateFile) SourceAttr() SourceAttr { + return t.sourceAttr +} + // Apply updates actualStateEntry to match t. func (t *TargetStateRemove) Apply( - system System, persistentState PersistentState, actualStateEntry ActualStateEntry, + system System, + persistentState PersistentState, + actualStateEntry ActualStateEntry, ) (bool, error) { if _, ok := actualStateEntry.(*ActualStateAbsent); ok { return false, nil @@ -192,14 +298,21 @@ func (t *TargetStateRemove) Evaluate() error { return nil } -// SkipApply implements TargetState.SkipApply. +// SkipApply implements TargetStateEntry.SkipApply. func (t *TargetStateRemove) SkipApply(persistentState PersistentState, targetAbsPath AbsPath) (bool, error) { return false, nil } +// SourceAttr implements TargetStateEntry.SourceAttr. +func (t *TargetStateRemove) SourceAttr() SourceAttr { + return SourceAttr{} +} + // Apply runs t. func (t *TargetStateScript) Apply( - system System, persistentState PersistentState, actualStateEntry ActualStateEntry, + system System, + persistentState PersistentState, + actualStateEntry ActualStateEntry, ) (bool, error) { skipApply, err := t.SkipApply(persistentState, actualStateEntry.Path()) if err != nil { @@ -220,13 +333,17 @@ func (t *TargetStateScript) Apply( } runAt := time.Now().UTC() if !isEmpty(contents) { - if err := system.RunScript(t.name, actualStateEntry.Path().Dir(), contents, t.interpreter); err != nil { + if err := system.RunScript(t.name, actualStateEntry.Path().Dir(), contents, RunScriptOptions{ + Condition: t.condition, + Interpreter: t.interpreter, + SourceRelPath: t.sourceRelPath, + }); err != nil { return false, err } } scriptStateKey := []byte(hex.EncodeToString(contentsSHA256)) - if err := persistentStateSet(persistentState, scriptStateBucket, scriptStateKey, &scriptState{ + if err := PersistentStateSet(persistentState, ScriptStateBucket, scriptStateKey, &scriptState{ Name: t.name, RunAt: runAt, }); err != nil { @@ -234,7 +351,7 @@ func (t *TargetStateScript) Apply( } entryStateKey := actualStateEntry.Path().Bytes() - if err := persistentStateSet(persistentState, EntryStateBucket, entryStateKey, &EntryState{ + if err := PersistentStateSet(persistentState, EntryStateBucket, entryStateKey, &EntryState{ Type: EntryStateTypeScript, ContentsSHA256: HexBytes(contentsSHA256), }); err != nil { @@ -262,8 +379,14 @@ func (t *TargetStateScript) Evaluate() error { return err } -// SkipApply implements TargetState.SkipApply. +// SkipApply implements TargetStateEntry.SkipApply. func (t *TargetStateScript) SkipApply(persistentState PersistentState, targetAbsPath AbsPath) (bool, error) { + switch contents, err := t.Contents(); { + case err != nil: + return false, err + case len(contents) == 0: + return true, nil + } switch t.condition { case ScriptConditionAlways: return false, nil @@ -273,7 +396,7 @@ func (t *TargetStateScript) SkipApply(persistentState PersistentState, targetAbs return false, err } scriptStateKey := []byte(hex.EncodeToString(contentsSHA256)) - switch scriptState, err := persistentState.Get(scriptStateBucket, scriptStateKey); { + switch scriptState, err := persistentState.Get(ScriptStateBucket, scriptStateKey); { case err != nil: return false, err case scriptState != nil: @@ -301,9 +424,16 @@ func (t *TargetStateScript) SkipApply(persistentState PersistentState, targetAbs return false, nil } +// SourceAttr implements TargetStateEntry.SourceAttr. +func (t *TargetStateScript) SourceAttr() SourceAttr { + return t.sourceAttr +} + // Apply updates actualStateEntry to match t. func (t *TargetStateSymlink) Apply( - system System, persistentState PersistentState, actualStateEntry ActualStateEntry, + system System, + persistentState PersistentState, + actualStateEntry ActualStateEntry, ) (bool, error) { linkname, err := t.Linkname() if err != nil { @@ -362,32 +492,12 @@ func (t *TargetStateSymlink) Evaluate() error { return err } -// SkipApply implements TargetState.SkipApply. -func (t *TargetStateSymlink) SkipApply( - persistentState PersistentState, targetAbsPath AbsPath, -) (bool, error) { +// SkipApply implements TargetStateEntry.SkipApply. +func (t *TargetStateSymlink) SkipApply(persistentState PersistentState, targetAbsPath AbsPath) (bool, error) { return false, nil } -// Apply renames actualStateEntry. -func (t *targetStateRenameDir) Apply( - system System, persistentState PersistentState, actualStateEntry ActualStateEntry, -) (bool, error) { - dir := actualStateEntry.Path().Dir() - return true, system.Rename(dir.Join(t.oldRelPath), dir.Join(t.newRelPath)) -} - -// EntryState returns t's entry state. -func (t *targetStateRenameDir) EntryState(umask fs.FileMode) (*EntryState, error) { - return nil, nil -} - -// Evaluate does nothing. -func (t *targetStateRenameDir) Evaluate() error { - return nil -} - -// SkipApply implements TargetState.SkipApply. -func (t *targetStateRenameDir) SkipApply(persistentState PersistentState, targetAbsPath AbsPath) (bool, error) { - return false, nil +// SourceAttr implements TargetStateEntry.SourceAttr. +func (t *TargetStateSymlink) SourceAttr() SourceAttr { + return t.sourceAttr } diff --git a/internal/chezmoi/targetstateentry_test.go b/internal/chezmoi/targetstateentry_test.go index 2a03f3196c8..409c3128995 100644 --- a/internal/chezmoi/targetstateentry_test.go +++ b/internal/chezmoi/targetstateentry_test.go @@ -3,21 +3,21 @@ package chezmoi import ( "fmt" "io/fs" - "sort" "testing" + "github.com/alecthomas/assert/v2" "github.com/muesli/combinator" - "github.com/stretchr/testify/require" - vfs "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + vfs "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" + "github.com/twpayne/chezmoi/v2/internal/chezmoimaps" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) func TestTargetStateEntryApply(t *testing.T) { targetStates := map[string]TargetStateEntry{ "dir": &TargetStateDir{ - perm: 0o777 &^ chezmoitest.Umask, + perm: fs.ModePerm &^ chezmoitest.Umask, }, "file": &TargetStateFile{ perm: 0o666 &^ chezmoitest.Umask, @@ -28,7 +28,7 @@ func TestTargetStateEntryApply(t *testing.T) { empty: true, }, "file_executable": &TargetStateFile{ - perm: 0o777 &^ chezmoitest.Umask, + perm: fs.ModePerm &^ chezmoitest.Umask, lazyContents: newLazyContents([]byte("#!/bin/sh\n")), }, "remove": &TargetStateRemove{}, @@ -37,9 +37,9 @@ func TestTargetStateEntryApply(t *testing.T) { }, } - actualStates := map[string]map[string]interface{}{ + actualStates := map[string]map[string]any{ "dir": { - "/home/user/target": &vfst.Dir{Perm: 0o777}, + "/home/user/target": &vfst.Dir{Perm: fs.ModePerm}, }, "file": { "/home/user/target": "# contents of file", @@ -49,15 +49,15 @@ func TestTargetStateEntryApply(t *testing.T) { }, "file_executable": { "/home/user/target": &vfst.File{ - Perm: 0o777, + Perm: fs.ModePerm, Contents: []byte("!/bin/sh\n"), }, }, "remove": { - "/home/user": &vfst.Dir{Perm: 0o777}, + "/home/user": &vfst.Dir{Perm: fs.ModePerm}, }, "symlink": { - "/home/user": map[string]interface{}{ + "/home/user": map[string]any{ "symlink-target": "", "target": &vfst.Symlink{Target: "symlink-target"}, }, @@ -67,33 +67,22 @@ func TestTargetStateEntryApply(t *testing.T) { }, } - 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, + TargetStateKey: chezmoimaps.SortedKeys(targetStates), + ActualDestDirStateKey: chezmoimaps.SortedKeys(actualStates), } var testCases []struct { TargetStateKey string ActualDestDirStateKey string } - require.NoError(t, combinator.Generate(&testCases, testData)) + assert.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) { + name := fmt.Sprintf("target_%s_actual_%s", tc.TargetStateKey, tc.ActualDestDirStateKey) + t.Run(name, func(t *testing.T) { targetState := targetStates[tc.TargetStateKey] actualState := actualStates[tc.ActualDestDirStateKey] @@ -102,15 +91,16 @@ func TestTargetStateEntryApply(t *testing.T) { // Read the initial destination state entry from fileSystem. actualStateEntry, err := NewActualStateEntry(system, NewAbsPath("/home/user/target"), nil, nil) - require.NoError(t, err) + assert.NoError(t, err) // Apply the target state entry. _, err = targetState.Apply(system, nil, actualStateEntry) - require.NoError(t, err) + assert.NoError(t, err) // Verify that the actual state entry matches the desired // state. - vfst.RunTests(t, fileSystem, "", vfst.TestPath("/home/user/target", targetStateTest(t, targetState)...)) + tests := vfst.TestPath("/home/user/target", targetStateTest(t, targetState)...) + vfst.RunTests(t, fileSystem, "", tests) }) }) } @@ -121,30 +111,26 @@ func targetStateTest(t *testing.T, ts TargetStateEntry) []vfst.PathTest { switch ts := ts.(type) { case *TargetStateRemove: return []vfst.PathTest{ - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), } case *TargetStateDir: return []vfst.PathTest{ - vfst.TestIsDir, + vfst.TestIsDir(), vfst.TestModePerm(ts.perm &^ chezmoitest.Umask), } case *TargetStateFile: expectedContents, err := ts.Contents() - require.NoError(t, err) + assert.NoError(t, err) return []vfst.PathTest{ - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestContents(expectedContents), vfst.TestModePerm(ts.perm &^ chezmoitest.Umask), } - case *targetStateRenameDir: - return []vfst.PathTest{ - vfst.TestDoesNotExist, - } case *TargetStateScript: return nil case *TargetStateSymlink: expectedLinkname, err := ts.Linkname() - require.NoError(t, err) + assert.NoError(t, err) return []vfst.PathTest{ vfst.TestModeType(fs.ModeSymlink), vfst.TestSymlinkTarget(expectedLinkname), diff --git a/internal/chezmoi/tarwritersystem.go b/internal/chezmoi/tarwritersystem.go index 5577b9ac4b8..e36d545d20e 100644 --- a/internal/chezmoi/tarwritersystem.go +++ b/internal/chezmoi/tarwritersystem.go @@ -4,31 +4,32 @@ import ( "archive/tar" "io" "io/fs" + "os/exec" ) -// A TARWriterSystem is a System that writes to a TAR archive. -type TARWriterSystem struct { +// A TarWriterSystem is a System that writes to a tar archive. +type TarWriterSystem struct { emptySystemMixin noUpdateSystemMixin tarWriter *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{ +// NewTarWriterSystem returns a new TarWriterSystem that writes a tar file to w. +func NewTarWriterSystem(w io.Writer, headerTemplate tar.Header) *TarWriterSystem { + return &TarWriterSystem{ tarWriter: tar.NewWriter(w), headerTemplate: headerTemplate, } } // Close closes m. -func (s *TARWriterSystem) Close() error { +func (s *TarWriterSystem) Close() error { return s.tarWriter.Close() } // Mkdir implements System.Mkdir. -func (s *TARWriterSystem) Mkdir(name AbsPath, perm fs.FileMode) error { +func (s *TarWriterSystem) Mkdir(name AbsPath, perm fs.FileMode) error { header := s.headerTemplate header.Typeflag = tar.TypeDir header.Name = name.String() + "/" @@ -36,13 +37,18 @@ func (s *TARWriterSystem) Mkdir(name AbsPath, perm fs.FileMode) error { return s.tarWriter.WriteHeader(&header) } +// RunCmd implements System.RunCmd. +func (s *TarWriterSystem) RunCmd(cmd *exec.Cmd) error { + return nil +} + // RunScript implements System.RunScript. -func (s *TARWriterSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte, interpreter *Interpreter) error { - return s.WriteFile(NewAbsPath(scriptname.String()), data, 0o700) +func (s *TarWriterSystem) RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) error { + return s.WriteFile(NewAbsPath(scriptName.String()), data, 0o700) } // WriteFile implements System.WriteFile. -func (s *TARWriterSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMode) error { +func (s *TarWriterSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMode) error { header := s.headerTemplate header.Typeflag = tar.TypeReg header.Name = filename.String() @@ -56,10 +62,10 @@ func (s *TARWriterSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileM } // WriteSymlink implements System.WriteSymlink. -func (s *TARWriterSystem) WriteSymlink(oldname string, newname AbsPath) error { +func (s *TarWriterSystem) WriteSymlink(oldName string, newName AbsPath) error { header := s.headerTemplate header.Typeflag = tar.TypeSymlink - header.Name = newname.String() - header.Linkname = oldname + header.Name = newName.String() + header.Linkname = oldName return s.tarWriter.WriteHeader(&header) } diff --git a/internal/chezmoi/tarwritersystem_test.go b/internal/chezmoi/tarwritersystem_test.go index d2d488080c4..ab42159e469 100644 --- a/internal/chezmoi/tarwritersystem_test.go +++ b/internal/chezmoi/tarwritersystem_test.go @@ -5,28 +5,29 @@ import ( "bytes" "context" "io" + "io/fs" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - vfs "github.com/twpayne/go-vfs/v4" + "github.com/alecthomas/assert/v2" + "github.com/coreos/go-semver/semver" + vfs "github.com/twpayne/go-vfs/v5" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) -var _ System = &TARWriterSystem{} +var _ System = &TarWriterSystem{} -func TestTARWriterSystem(t *testing.T) { - chezmoitest.WithTestFS(t, map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ +func TestTarWriterSystem(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ ".chezmoiignore": "README.md\n", ".chezmoiremove": "*.txt\n", ".chezmoiversion": "1.2.3\n", - ".chezmoitemplates": map[string]interface{}{ + ".chezmoitemplates": map[string]any{ "template": "# contents of .chezmoitemplates/template\n", }, "README.md": "", - "dot_dir": map[string]interface{}{ + "dot_dir": map[string]any{ "file": "# contents of .dir/file\n", }, "run_script": "# contents of script\n", @@ -37,19 +38,26 @@ func TestTARWriterSystem(t *testing.T) { system := NewRealSystem(fileSystem) s := NewSourceState( WithBaseSystem(system), + WithDestDir(NewAbsPath("/home/user")), WithSourceDir(NewAbsPath("/home/user/.local/share/chezmoi")), WithSystem(system), + WithVersion(semver.Version{ + Major: 1, + Minor: 2, + Patch: 3, + }), ) - require.NoError(t, s.Read(ctx, nil)) + assert.NoError(t, s.Read(ctx, nil)) requireEvaluateAll(t, s, system) b := &bytes.Buffer{} - tarWriterSystem := NewTARWriterSystem(b, tar.Header{}) + tarWriterSystem := NewTarWriterSystem(b, tar.Header{}) persistentState := NewMockPersistentState() - require.NoError(t, s.applyAll(tarWriterSystem, system, persistentState, EmptyAbsPath, ApplyOptions{ - Include: NewEntryTypeSet(EntryTypesAll), - })) - require.NoError(t, tarWriterSystem.Close()) + err := s.applyAll(tarWriterSystem, system, persistentState, EmptyAbsPath, ApplyOptions{ + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), + }) + assert.NoError(t, err) + assert.NoError(t, tarWriterSystem.Close()) r := tar.NewReader(b) for _, tc := range []struct { @@ -62,7 +70,7 @@ func TestTARWriterSystem(t *testing.T) { { expectedTypeflag: tar.TypeDir, expectedName: ".dir/", - expectedMode: 0o777 &^ int64(chezmoitest.Umask), + expectedMode: int64(fs.ModePerm &^ chezmoitest.Umask), }, { expectedTypeflag: tar.TypeReg, @@ -84,7 +92,7 @@ func TestTARWriterSystem(t *testing.T) { } { t.Run(tc.expectedName, func(t *testing.T) { header, err := r.Next() - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, tc.expectedTypeflag, header.Typeflag) assert.Equal(t, tc.expectedName, header.Name) assert.Equal(t, tc.expectedMode, header.Mode) @@ -92,12 +100,12 @@ func TestTARWriterSystem(t *testing.T) { assert.Equal(t, int64(len(tc.expectedContents)), header.Size) if tc.expectedContents != nil { actualContents, err := io.ReadAll(r) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, tc.expectedContents, actualContents) } }) } - _, err := r.Next() + _, err = r.Next() assert.Equal(t, io.EOF, err) }) } diff --git a/internal/chezmoi/template.go b/internal/chezmoi/template.go new file mode 100644 index 00000000000..195cda8c0e2 --- /dev/null +++ b/internal/chezmoi/template.go @@ -0,0 +1,128 @@ +package chezmoi + +import ( + "bytes" + "strings" + "text/template" + + "github.com/mitchellh/copystructure" +) + +// A Template extends text/template.Template with support for directives. +type Template struct { + name string + template *template.Template + options TemplateOptions +} + +// TemplateOptions are template options that can be set with directives. +type TemplateOptions struct { + LeftDelimiter string + LineEnding string + RightDelimiter string + Options []string +} + +// ParseTemplate parses a template named name from data with the given funcs and +// templateOptions. +func ParseTemplate(name string, data []byte, funcs template.FuncMap, options TemplateOptions) (*Template, error) { + contents := options.parseAndRemoveDirectives(data) + template, err := template.New(name). + Option(options.Options...). + Delims(options.LeftDelimiter, options.RightDelimiter). + Funcs(funcs). + Parse(string(contents)) + if err != nil { + return nil, err + } + return &Template{ + name: name, + template: template, + options: options, + }, nil +} + +// AddParseTree adds tmpl's parse tree to t. +func (t *Template) AddParseTree(tmpl *Template) (*Template, error) { + var err error + t.template, err = t.template.AddParseTree(tmpl.name, tmpl.template.Tree) + return t, err +} + +// Execute executes t with data. +func (t *Template) Execute(data any) ([]byte, error) { + if data != nil { + // Make a deep copy of data, in case any template functions modify it. + var err error + data, err = copystructure.Copy(data) + if err != nil { + return nil, err + } + } + + var builder strings.Builder + if err := t.template.ExecuteTemplate(&builder, t.name, data); err != nil { + return nil, err + } + return []byte(replaceLineEndings(builder.String(), t.options.LineEnding)), nil +} + +// parseAndRemoveDirectives updates o by parsing all template directives in data +// and returns data with the lines containing directives removed. The lines are +// removed so that any delimiters do not break template parsing. +func (o *TemplateOptions) parseAndRemoveDirectives(data []byte) []byte { + directiveMatches := templateDirectiveRx.FindAllSubmatchIndex(data, -1) + if directiveMatches == nil { + return data + } + + // Parse options from directives. + for _, directiveMatch := range directiveMatches { + keyValuePairMatches := templateDirectiveKeyValuePairRx.FindAllSubmatch(data[directiveMatch[2]:directiveMatch[3]], -1) + for _, keyValuePairMatch := range keyValuePairMatches { + key := string(keyValuePairMatch[1]) + value := maybeUnquote(string(keyValuePairMatch[2])) + switch key { + case "left-delimiter": + o.LeftDelimiter = value + case "line-ending", "line-endings": + switch string(keyValuePairMatch[2]) { + case "crlf": + o.LineEnding = "\r\n" + case "lf": + o.LineEnding = "\n" + case "native": + o.LineEnding = nativeLineEnding + default: + o.LineEnding = value + } + case "right-delimiter": + o.RightDelimiter = value + case "missing-key": + o.Options = append(o.Options, "missingkey="+value) + } + } + } + + return removeMatches(data, directiveMatches) +} + +// removeMatches returns data with matchesIndexes removed. +func removeMatches(data []byte, matchesIndexes [][]int) []byte { + slices := make([][]byte, len(matchesIndexes)+1) + slices[0] = data[:matchesIndexes[0][0]] + for i, matchIndexes := range matchesIndexes[1:] { + slices[i+1] = data[matchesIndexes[i][1]:matchIndexes[0]] + } + slices[len(matchesIndexes)] = data[matchesIndexes[len(matchesIndexes)-1][1]:] + return bytes.Join(slices, nil) +} + +// replaceLineEndings replaces all line endings in s with lineEnding. If +// lineEnding is empty it returns s unchanged. +func replaceLineEndings(s, lineEnding string) string { + if lineEnding == "" { + return s + } + return lineEndingRx.ReplaceAllString(s, lineEnding) +} diff --git a/internal/chezmoi/template_test.go b/internal/chezmoi/template_test.go new file mode 100644 index 00000000000..ca1c15cdc61 --- /dev/null +++ b/internal/chezmoi/template_test.go @@ -0,0 +1,75 @@ +package chezmoi + +import ( + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/twpayne/chezmoi/v2/internal/chezmoitest" +) + +func TestTemplateParseAndExecute(t *testing.T) { + for _, tc := range []struct { + name string + dataStr string + expectedStr string + }{ + { + name: "missing_key", + dataStr: chezmoitest.JoinLines( + "# chezmoi:template:missing-key=invalid", + "{{ .missing }}", + ), + expectedStr: chezmoitest.JoinLines( + "", + ), + }, + { + name: "delimiters", + dataStr: chezmoitest.JoinLines( + "# chezmoi:template:left-delimiter=[[ right-delimiter=]]", + "[[ 0 ]]", + ), + expectedStr: chezmoitest.JoinLines( + "0", + ), + }, + { + name: "line_ending_crlf", + dataStr: "" + + "unix\n" + + "\n" + + "windows\r\n" + + "\r\n" + + "# chezmoi:template:line-ending=crlf\n", + expectedStr: "" + + "unix\r\n" + + "\r\n" + + "windows\r\n" + + "\r\n", + }, + { + name: "line_ending_lf", + dataStr: "" + + "unix\n" + + "\n" + + "windows\r\n" + + "\r\n" + + "# chezmoi:template:line-ending=lf\n", + expectedStr: chezmoitest.JoinLines( + "unix", + "", + "windows", + "", + ), + }, + } { + t.Run(tc.name, func(t *testing.T) { + tmpl, err := ParseTemplate(tc.name, []byte(tc.dataStr), nil, TemplateOptions{}) + assert.NoError(t, err) + actual, err := tmpl.Execute(nil) + assert.NoError(t, err) + assert.Equal(t, tc.expectedStr, string(actual)) + }) + } +} diff --git a/internal/chezmoi/zipwritersystem.go b/internal/chezmoi/zipwritersystem.go index 1674f3faba2..b93c20807d5 100644 --- a/internal/chezmoi/zipwritersystem.go +++ b/internal/chezmoi/zipwritersystem.go @@ -1,10 +1,12 @@ package chezmoi import ( - "archive/zip" "io" "io/fs" + "os/exec" "time" + + "github.com/klauspost/compress/zip" ) // A ZIPWriterSystem is a System that writes to a ZIP archive. @@ -40,41 +42,46 @@ func (s *ZIPWriterSystem) Mkdir(name AbsPath, perm fs.FileMode) error { return err } +// RunCmd implements System.RunCmd. +func (s *ZIPWriterSystem) RunCmd(cmd *exec.Cmd) error { + return nil +} + // RunScript implements System.RunScript. -func (s *ZIPWriterSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte, interpreter *Interpreter) error { - return s.WriteFile(NewAbsPath(scriptname.String()), data, 0o700) +func (s *ZIPWriterSystem) RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) error { + return s.WriteFile(NewAbsPath(scriptName.String()), data, 0o700) } // WriteFile implements System.WriteFile. func (s *ZIPWriterSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMode) error { - fh := zip.FileHeader{ + fileHeader := zip.FileHeader{ Name: filename.String(), Method: zip.Deflate, Modified: s.modified, UncompressedSize64: uint64(len(data)), } - fh.SetMode(perm) - fw, err := s.zipWriter.CreateHeader(&fh) + fileHeader.SetMode(perm) + fileWriter, err := s.zipWriter.CreateHeader(&fileHeader) if err != nil { return err } - _, err = fw.Write(data) + _, err = fileWriter.Write(data) return err } // WriteSymlink implements System.WriteSymlink. -func (s *ZIPWriterSystem) WriteSymlink(oldname string, newname AbsPath) error { - data := []byte(oldname) - fh := zip.FileHeader{ - Name: newname.String(), +func (s *ZIPWriterSystem) WriteSymlink(oldName string, newName AbsPath) error { + data := []byte(oldName) + fileHeader := zip.FileHeader{ + Name: newName.String(), Modified: s.modified, UncompressedSize64: uint64(len(data)), } - fh.SetMode(fs.ModeSymlink) - fw, err := s.zipWriter.CreateHeader(&fh) + fileHeader.SetMode(fs.ModeSymlink) + fileWriter, err := s.zipWriter.CreateHeader(&fileHeader) if err != nil { return err } - _, err = fw.Write(data) + _, err = fileWriter.Write(data) return err } diff --git a/internal/chezmoi/zipwritersystem_test.go b/internal/chezmoi/zipwritersystem_test.go index 23469acb0e3..d58b432cb1d 100644 --- a/internal/chezmoi/zipwritersystem_test.go +++ b/internal/chezmoi/zipwritersystem_test.go @@ -1,7 +1,6 @@ package chezmoi import ( - "archive/zip" "bytes" "context" "io" @@ -9,9 +8,10 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - vfs "github.com/twpayne/go-vfs/v4" + "github.com/alecthomas/assert/v2" + "github.com/coreos/go-semver/semver" + "github.com/klauspost/compress/zip" + vfs "github.com/twpayne/go-vfs/v5" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -19,16 +19,16 @@ import ( var _ System = &ZIPWriterSystem{} func TestZIPWriterSystem(t *testing.T) { - chezmoitest.WithTestFS(t, map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ ".chezmoiignore": "README.md\n", ".chezmoiremove": "*.txt\n", ".chezmoiversion": "1.2.3\n", - ".chezmoitemplates": map[string]interface{}{ + ".chezmoitemplates": map[string]any{ "template": "# contents of .chezmoitemplates/template\n", }, "README.md": "", - "dot_dir": map[string]interface{}{ + "dot_dir": map[string]any{ "file": "# contents of .dir/file\n", }, "run_script": "# contents of script\n", @@ -39,22 +39,29 @@ func TestZIPWriterSystem(t *testing.T) { system := NewRealSystem(fileSystem) s := NewSourceState( WithBaseSystem(system), + WithDestDir(NewAbsPath("/home/user")), WithSourceDir(NewAbsPath("/home/user/.local/share/chezmoi")), WithSystem(system), + WithVersion(semver.Version{ + Major: 1, + Minor: 2, + Patch: 3, + }), ) - require.NoError(t, s.Read(ctx, nil)) + assert.NoError(t, s.Read(ctx, nil)) requireEvaluateAll(t, s, system) b := &bytes.Buffer{} zipWriterSystem := NewZIPWriterSystem(b, time.Now().UTC()) persistentState := NewMockPersistentState() - require.NoError(t, s.applyAll(zipWriterSystem, system, persistentState, EmptyAbsPath, ApplyOptions{ - Include: NewEntryTypeSet(EntryTypesAll), - })) - require.NoError(t, zipWriterSystem.Close()) + err := s.applyAll(zipWriterSystem, system, persistentState, EmptyAbsPath, ApplyOptions{ + Filter: NewEntryTypeFilter(EntryTypesAll, EntryTypesNone), + }) + assert.NoError(t, err) + assert.NoError(t, zipWriterSystem.Close()) r, err := zip.NewReader(bytes.NewReader(b.Bytes()), int64(b.Len())) - require.NoError(t, err) + assert.NoError(t, err) expectedFiles := []struct { name string method uint16 @@ -63,7 +70,7 @@ func TestZIPWriterSystem(t *testing.T) { }{ { name: ".dir", - mode: (fs.ModeDir | 0o777) &^ chezmoitest.Umask, + mode: (fs.ModeDir | fs.ModePerm) &^ chezmoitest.Umask, }, { name: ".dir/file", @@ -83,7 +90,7 @@ func TestZIPWriterSystem(t *testing.T) { contents: []byte(".dir/subdir/file"), }, } - require.Len(t, r.File, len(expectedFiles)) + assert.Equal(t, len(expectedFiles), len(r.File)) for i, expectedFile := range expectedFiles { t.Run(expectedFile.name, func(t *testing.T) { actualFile := r.File[i] @@ -92,9 +99,9 @@ func TestZIPWriterSystem(t *testing.T) { assert.Equal(t, expectedFile.mode, actualFile.Mode()) if expectedFile.contents != nil { rc, err := actualFile.Open() - require.NoError(t, err) + assert.NoError(t, err) actualContents, err := io.ReadAll(rc) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, expectedFile.contents, actualContents) } }) diff --git a/internal/chezmoiassert/chezmoiassert.go b/internal/chezmoiassert/chezmoiassert.go new file mode 100644 index 00000000000..afc328068bd --- /dev/null +++ b/internal/chezmoiassert/chezmoiassert.go @@ -0,0 +1,43 @@ +// Package chezmoiassert implements testing assertions not implemented by +// github.com/alecthomas/assert/v2. +package chezmoiassert + +import ( + "fmt" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func PanicsWithError(tb testing.TB, expected error, fn func(), msgAndArgs ...interface{}) { + tb.Helper() + defer func() { + if value, ok := recover().(error); ok { + assert.Equal(tb, expected, value, msgAndArgs...) + } else { + msg := formatMsgAndArgs("Expected function to panic with error", msgAndArgs...) + tb.Fatal(msg) + } + }() + fn() +} + +func PanicsWithErrorString(tb testing.TB, errString string, fn func(), msgAndArgs ...interface{}) { + tb.Helper() + defer func() { + if value, ok := recover().(error); ok { + assert.EqualError(tb, value, errString, msgAndArgs...) + } else { + msg := formatMsgAndArgs("Expected function to panic with error string", msgAndArgs...) + tb.Fatal(msg) + } + }() + fn() +} + +func formatMsgAndArgs(dflt string, msgAndArgs ...interface{}) string { + if len(msgAndArgs) == 0 { + return dflt + } + return fmt.Sprintf(msgAndArgs[0].(string), msgAndArgs[1:]...) //nolint:forcetypeassert +} diff --git a/internal/chezmoibubbles/boolinputmodel.go b/internal/chezmoibubbles/boolinputmodel.go new file mode 100644 index 00000000000..8d188808158 --- /dev/null +++ b/internal/chezmoibubbles/boolinputmodel.go @@ -0,0 +1,79 @@ +package chezmoibubbles + +import ( + "strconv" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +type BoolInputModel struct { + textInput textinput.Model + defaultValue *bool + canceled bool +} + +func NewBoolInputModel(prompt string, defaultValue *bool) BoolInputModel { + textInput := textinput.New() + textInput.Prompt = prompt + "? " + textInput.Placeholder = "bool" + if defaultValue != nil { + textInput.Placeholder += ", default " + strconv.FormatBool(*defaultValue) + } + textInput.Validate = func(value string) error { + if value == "" && defaultValue != nil { + return nil + } + _, err := chezmoi.ParseBool(value) + return err + } + textInput.Focus() + return BoolInputModel{ + textInput: textInput, + defaultValue: defaultValue, + } +} + +func (m BoolInputModel) Canceled() bool { + return m.canceled +} + +func (m BoolInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m BoolInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + case tea.KeyEnter: + if m.defaultValue != nil { + m.textInput.SetValue(strconv.FormatBool(*m.defaultValue)) + return m, tea.Quit + } + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + if _, err := chezmoi.ParseBool(m.textInput.Value()); err == nil { + return m, tea.Quit + } + return m, cmd +} + +func (m BoolInputModel) Value() bool { + valueStr := m.textInput.Value() + if valueStr == "" && m.defaultValue != nil { + return *m.defaultValue + } + value, _ := chezmoi.ParseBool(valueStr) + return value +} + +func (m BoolInputModel) View() string { + return m.textInput.View() +} diff --git a/internal/chezmoibubbles/boolinputmodel_test.go b/internal/chezmoibubbles/boolinputmodel_test.go new file mode 100644 index 00000000000..0ad988c0463 --- /dev/null +++ b/internal/chezmoibubbles/boolinputmodel_test.go @@ -0,0 +1,70 @@ +package chezmoibubbles + +import ( + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestBoolInputModel(t *testing.T) { + for _, tc := range []struct { + name string + defaultValue *bool + input string + expectedCanceled bool + expectedValue bool + }{ + { + name: "empty_with_default", + defaultValue: newValue(true), + input: "\r", + expectedValue: true, + }, + { + name: "cancel_ctrlc", + input: "\x03", + expectedCanceled: true, + }, + { + name: "cancel_esc", + input: "\x1b", + expectedCanceled: true, + }, + { + name: "true", + input: "t", + expectedValue: true, + }, + { + name: "false", + input: "f", + expectedValue: false, + }, + { + name: "yes", + input: "y", + expectedValue: true, + }, + { + name: "no", + input: "n", + expectedValue: false, + }, + { + name: "one", + input: "1", + expectedValue: true, + }, + { + name: "zero", + input: "0", + expectedValue: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualModel := testRunModelWithInput(t, NewBoolInputModel("prompt", tc.defaultValue), tc.input) + assert.Equal(t, tc.expectedCanceled, actualModel.Canceled()) + assert.Equal(t, tc.expectedValue, actualModel.Value()) + }) + } +} diff --git a/internal/chezmoibubbles/chezmoibubbles.go b/internal/chezmoibubbles/chezmoibubbles.go new file mode 100644 index 00000000000..0958cfeca89 --- /dev/null +++ b/internal/chezmoibubbles/chezmoibubbles.go @@ -0,0 +1,3 @@ +// Package chezmoibubbles provides text user interface components for chezmoi +// using github.com/charmbracelet/bubbletea. +package chezmoibubbles diff --git a/internal/chezmoibubbles/chezmoibubbles_test.go b/internal/chezmoibubbles/chezmoibubbles_test.go new file mode 100644 index 00000000000..8305e888ca1 --- /dev/null +++ b/internal/chezmoibubbles/chezmoibubbles_test.go @@ -0,0 +1,52 @@ +package chezmoibubbles + +import ( + "testing" + + "github.com/alecthomas/assert/v2" + tea "github.com/charmbracelet/bubbletea" + + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" +) + +var keyTypes = chezmoiset.New( + tea.KeyCtrlC, + tea.KeyEnter, + tea.KeyEsc, +) + +func makeKeyMsg(r rune) tea.Msg { + key := tea.Key{ + Type: tea.KeyRunes, + Runes: []rune{r}, + } + if keyTypes.Contains(tea.KeyType(r)) { + key = tea.Key{ + Type: tea.KeyType(r), + } + } + return tea.KeyMsg(key) +} + +func makeKeyMsgs(s string) []tea.Msg { + msgs := make([]tea.Msg, len(s)) + for i, r := range s { + msgs[i] = makeKeyMsg(r) + } + return msgs +} + +func testRunModelWithInput[M tea.Model](t *testing.T, model M, input string) M { + t.Helper() + for _, msg := range makeKeyMsgs(input) { + m, _ := model.Update(msg) + var ok bool + model, ok = m.(M) + assert.True(t, ok) + } + return model +} + +func newValue[T any](value T) *T { + return &value +} diff --git a/internal/chezmoibubbles/choiceinputmodel.go b/internal/chezmoibubbles/choiceinputmodel.go new file mode 100644 index 00000000000..b1285e45fde --- /dev/null +++ b/internal/chezmoibubbles/choiceinputmodel.go @@ -0,0 +1,95 @@ +package chezmoibubbles + +import ( + "errors" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" +) + +type ChoiceInputModel struct { + textInput textinput.Model + uniqueAbbreviations map[string]string + defaultValue *string + canceled bool +} + +func NewChoiceInputModel(prompt string, choices []string, defaultValue *string) ChoiceInputModel { + textInput := textinput.New() + textInput.Prompt = prompt + "? " + textInput.Placeholder = strings.Join(choices, "/") + if defaultValue != nil { + textInput.Placeholder += ", default " + *defaultValue + } + allAbbreviations := chezmoiset.New[string]() + for _, choice := range choices { + for i := range choice { + allAbbreviations.Add(choice[:i+1]) + } + } + textInput.Validate = func(s string) error { + if s == "" && defaultValue != nil { + return nil + } + if allAbbreviations.Contains(s) { + return nil + } + return errors.New("unknown or ambiguous choice") + } + textInput.Focus() + return ChoiceInputModel{ + textInput: textInput, + uniqueAbbreviations: chezmoi.UniqueAbbreviations(choices), + defaultValue: defaultValue, + } +} + +func (m ChoiceInputModel) Canceled() bool { + return m.canceled +} + +func (m ChoiceInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m ChoiceInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + case tea.KeyEnter: + value := m.textInput.Value() + if value == "" && m.defaultValue != nil { + m.textInput.SetValue(*m.defaultValue) + return m, tea.Quit + } else if value, ok := m.uniqueAbbreviations[value]; ok { + m.textInput.SetValue(value) + return m, tea.Quit + } + } + } + + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + if _, ok := m.uniqueAbbreviations[m.textInput.Value()]; ok { + return m, tea.Quit + } + return m, cmd +} + +func (m ChoiceInputModel) Value() string { + value := m.textInput.Value() + if value == "" && m.defaultValue != nil { + return *m.defaultValue + } + return m.uniqueAbbreviations[value] +} + +func (m ChoiceInputModel) View() string { + return m.textInput.View() +} diff --git a/internal/chezmoibubbles/choiceinputmodel_test.go b/internal/chezmoibubbles/choiceinputmodel_test.go new file mode 100644 index 00000000000..fb19dcaf929 --- /dev/null +++ b/internal/chezmoibubbles/choiceinputmodel_test.go @@ -0,0 +1,78 @@ +package chezmoibubbles + +import ( + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestChoiceInputModel(t *testing.T) { + choicesYesNoAll := []string{"yes", "no", "all"} + for _, tc := range []struct { + name string + choices []string + defaultValue *string + input string + expectedCanceled bool + expectedValue string + }{ + { + name: "empty_with_default", + choices: choicesYesNoAll, + defaultValue: newValue("all"), + input: "\r", + expectedValue: "all", + }, + { + name: "cancel_ctrlc", + input: "\x03", + expectedCanceled: true, + }, + { + name: "cancel_esc", + input: "\x1b", + expectedCanceled: true, + }, + { + name: "y", + choices: choicesYesNoAll, + input: "y", + expectedValue: "yes", + }, + { + name: "n", + choices: choicesYesNoAll, + input: "n", + expectedValue: "no", + }, + { + name: "a", + choices: choicesYesNoAll, + input: "a", + expectedValue: "all", + }, + { + name: "ambiguous_a", + choices: []string{"aaa", "abb", "bbb"}, + input: "a", + }, + { + name: "unambiguous_b", + choices: []string{"aaa", "abb", "bbb"}, + input: "b", + expectedValue: "bbb", + }, + { + name: "ambiguous_resolved", + choices: []string{"aaa", "abb", "bbb"}, + input: "aa", + expectedValue: "aaa", + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualModel := testRunModelWithInput(t, NewChoiceInputModel("prompt", tc.choices, tc.defaultValue), tc.input) + assert.Equal(t, tc.expectedCanceled, actualModel.Canceled()) + assert.Equal(t, tc.expectedValue, actualModel.Value()) + }) + } +} diff --git a/internal/chezmoibubbles/intinputmodel.go b/internal/chezmoibubbles/intinputmodel.go new file mode 100644 index 00000000000..20bf462be86 --- /dev/null +++ b/internal/chezmoibubbles/intinputmodel.go @@ -0,0 +1,77 @@ +package chezmoibubbles + +import ( + "strconv" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type IntInputModel struct { + textInput textinput.Model + defaultValue *int64 + canceled bool +} + +func NewIntInputModel(prompt string, defaultValue *int64) IntInputModel { + textInput := textinput.New() + textInput.Prompt = prompt + "? " + textInput.Placeholder = "int" + if defaultValue != nil { + textInput.Placeholder += ", default " + strconv.FormatInt(*defaultValue, 10) + } + textInput.Validate = func(value string) error { + if value == "" && defaultValue != nil { + return nil + } + if value == "-" { + return nil + } + _, err := strconv.ParseInt(value, 10, 64) + return err + } + textInput.Focus() + return IntInputModel{ + textInput: textInput, + defaultValue: defaultValue, + } +} + +func (m IntInputModel) Canceled() bool { + return m.canceled +} + +func (m IntInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m IntInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + case tea.KeyEnter: + if m.textInput.Value() == "" && m.defaultValue != nil { + m.textInput.SetValue(strconv.FormatInt(*m.defaultValue, 10)) + } + return m, tea.Quit + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m IntInputModel) Value() int64 { + valueStr := m.textInput.Value() + if valueStr == "" && m.defaultValue != nil { + return *m.defaultValue + } + value, _ := strconv.ParseInt(valueStr, 10, 64) + return value +} + +func (m IntInputModel) View() string { + return m.textInput.View() +} diff --git a/internal/chezmoibubbles/intinputmodel_test.go b/internal/chezmoibubbles/intinputmodel_test.go new file mode 100644 index 00000000000..d6d847f4dbb --- /dev/null +++ b/internal/chezmoibubbles/intinputmodel_test.go @@ -0,0 +1,60 @@ +package chezmoibubbles + +import ( + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestIntInputModel(t *testing.T) { + for _, tc := range []struct { + name string + defaultValue *int64 + input string + expectedCanceled bool + expectedValue int64 + }{ + { + name: "empty_with_default", + defaultValue: newValue(int64(1)), + input: "\r", + expectedValue: 1, + }, + { + name: "cancel_ctrlc", + input: "\x03", + expectedCanceled: true, + }, + { + name: "cancel_esc", + input: "\x1b", + expectedCanceled: true, + }, + { + name: "one_enter", + input: "1\r", + expectedValue: 1, + }, + { + name: "minus_one_enter", + input: "-1\r", + expectedValue: -1, + }, + { + name: "minus_enter", + input: "-\r", + expectedValue: 0, + }, + { + name: "one_invalid_enter", + input: "1a\r", + expectedValue: 1, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualModel := testRunModelWithInput(t, NewIntInputModel("prompt", tc.defaultValue), tc.input) + assert.Equal(t, tc.expectedCanceled, actualModel.Canceled()) + assert.Equal(t, tc.expectedValue, actualModel.Value()) + }) + } +} diff --git a/internal/chezmoibubbles/passwordinputmodel.go b/internal/chezmoibubbles/passwordinputmodel.go new file mode 100644 index 00000000000..af219a1bd20 --- /dev/null +++ b/internal/chezmoibubbles/passwordinputmodel.go @@ -0,0 +1,53 @@ +package chezmoibubbles + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type PasswordInputModel struct { + textInput textinput.Model + canceled bool +} + +func NewPasswordInputModel(prompt string) PasswordInputModel { + textInput := textinput.New() + textInput.Prompt = prompt + textInput.Placeholder = "password" + textInput.EchoMode = textinput.EchoNone + textInput.Focus() + return PasswordInputModel{ + textInput: textInput, + } +} + +func (m PasswordInputModel) Canceled() bool { + return m.canceled +} + +func (m PasswordInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m PasswordInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + case tea.KeyEnter: + return m, tea.Quit + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m PasswordInputModel) Value() string { + return m.textInput.Value() +} + +func (m PasswordInputModel) View() string { + return m.textInput.View() +} diff --git a/internal/chezmoibubbles/passwordinputmodel_test.go b/internal/chezmoibubbles/passwordinputmodel_test.go new file mode 100644 index 00000000000..88891e51f96 --- /dev/null +++ b/internal/chezmoibubbles/passwordinputmodel_test.go @@ -0,0 +1,48 @@ +package chezmoibubbles + +import ( + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestPasswordInputModel(t *testing.T) { + for _, tc := range []struct { + name string + input string + expectedCanceled bool + expectedValue string + }{ + { + name: "empty", + input: "\r", + }, + { + name: "cancel_ctrlc", + input: "\x03", + expectedCanceled: true, + }, + { + name: "cancel_esc", + input: "\x1b", + expectedCanceled: true, + }, + { + name: "password_enter", + input: "password\r", + expectedValue: "password", + }, + { + name: "password_ctrlc", + input: "password\x03", + expectedCanceled: true, + expectedValue: "password", + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualModel := testRunModelWithInput(t, NewPasswordInputModel("prompt"), tc.input) + assert.Equal(t, tc.expectedCanceled, actualModel.Canceled()) + assert.Equal(t, tc.expectedValue, actualModel.Value()) + }) + } +} diff --git a/internal/chezmoibubbles/stringinputmodel.go b/internal/chezmoibubbles/stringinputmodel.go new file mode 100644 index 00000000000..3c56d77955f --- /dev/null +++ b/internal/chezmoibubbles/stringinputmodel.go @@ -0,0 +1,61 @@ +package chezmoibubbles + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type StringInputModel struct { + textInput textinput.Model + defaultValue *string + canceled bool +} + +func NewStringInputModel(prompt string, defaultValue *string) StringInputModel { + textInput := textinput.New() + textInput.Prompt = prompt + "? " + textInput.Placeholder = "string" + if defaultValue != nil { + textInput.Placeholder += ", default " + *defaultValue + } + textInput.Focus() + return StringInputModel{ + textInput: textInput, + defaultValue: defaultValue, + } +} + +func (m StringInputModel) Canceled() bool { + return m.canceled +} + +func (m StringInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m StringInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + case tea.KeyEnter: + return m, tea.Quit + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m StringInputModel) Value() string { + value := m.textInput.Value() + if value == "" && m.defaultValue != nil { + return *m.defaultValue + } + return value +} + +func (m StringInputModel) View() string { + return m.textInput.View() +} diff --git a/internal/chezmoibubbles/stringinputmodel_test.go b/internal/chezmoibubbles/stringinputmodel_test.go new file mode 100644 index 00000000000..9766397de9d --- /dev/null +++ b/internal/chezmoibubbles/stringinputmodel_test.go @@ -0,0 +1,55 @@ +package chezmoibubbles + +import ( + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestStringInputModel(t *testing.T) { + for _, tc := range []struct { + name string + defaultValue *string + input string + expectedCanceled bool + expectedValue string + }{ + { + name: "empty", + input: "\r", + }, + { + name: "empty_with_default", + defaultValue: newValue("default"), + input: "\r", + expectedValue: "default", + }, + { + name: "cancel_ctrlc", + input: "\x03", + expectedCanceled: true, + }, + { + name: "cancel_esc", + input: "\x1b", + expectedCanceled: true, + }, + { + name: "value_enter", + input: "value\r", + expectedValue: "value", + }, + { + name: "value_ctrlc", + input: "value\x03", + expectedCanceled: true, + expectedValue: "value", + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualModel := testRunModelWithInput(t, NewStringInputModel("prompt", tc.defaultValue), tc.input) + assert.Equal(t, tc.expectedCanceled, actualModel.Canceled()) + assert.Equal(t, tc.expectedValue, actualModel.Value()) + }) + } +} diff --git a/internal/chezmoierrors/chezmoierrors.go b/internal/chezmoierrors/chezmoierrors.go new file mode 100644 index 00000000000..89aaff7fa7f --- /dev/null +++ b/internal/chezmoierrors/chezmoierrors.go @@ -0,0 +1,34 @@ +// Package chezmoierrors contains convenience functions for combining multiple +// errors. +package chezmoierrors + +import "errors" + +// Combine combines all non-nil errors in errs into one. If there are no non-nil +// errors, it returns nil. If there is exactly one non-nil error then it returns +// that error. Otherwise, it returns the non-nil errors combined with +// errors.Join. +func Combine(errs ...error) error { + nonNilErrs := make([]error, 0, len(errs)) + for _, err := range errs { + if err != nil { + nonNilErrs = append(nonNilErrs, err) + } + } + switch len(nonNilErrs) { + case 0: + return nil + case 1: + return nonNilErrs[0] + default: + return errors.Join(nonNilErrs...) + } +} + +// CombineFunc combines the error pointed to by errp with the result of calling +// f. +func CombineFunc(errp *error, f func() error) { + if err := f(); err != nil { + *errp = Combine(*errp, err) + } +} diff --git a/internal/chezmoigit/chezmoigit.go b/internal/chezmoigit/chezmoigit.go new file mode 100644 index 00000000000..791b3405414 --- /dev/null +++ b/internal/chezmoigit/chezmoigit.go @@ -0,0 +1,2 @@ +// Package chezmoigit contains functions for interacting with git. +package chezmoigit diff --git a/internal/git/status.go b/internal/chezmoigit/status.go similarity index 93% rename from internal/git/status.go rename to internal/chezmoigit/status.go index 618d36eee88..6870220c1a5 100644 --- a/internal/git/status.go +++ b/internal/chezmoigit/status.go @@ -1,9 +1,8 @@ -package git +package chezmoigit import ( "bufio" "bytes" - "fmt" "regexp" "strconv" ) @@ -127,14 +126,16 @@ var ( ) func (e ParseError) Error() string { - return fmt.Sprintf("%s: parse error", string(e)) + return string(e) + ": parse error" } // ParseStatusPorcelainV2 parses the output of -// git status --ignored --porcelain=v2 +// +// git status --ignored --porcelain=v2 +// // See https://git-scm.com/docs/git-status. func ParseStatusPorcelainV2(output []byte) (*Status, error) { - status := &Status{} + var status Status s := bufio.NewScanner(bytes.NewReader(output)) for s.Scan() { text := s.Text() @@ -266,18 +267,25 @@ func ParseStatusPorcelainV2(output []byte) (*Status, error) { if err := s.Err(); err != nil { return nil, err } - if status.Empty() { - return nil, nil - } - return status, nil + return &status, nil } // Empty returns true if s is empty. func (s *Status) Empty() bool { - return s == nil || true && - len(s.Ignored) == 0 && - len(s.Ordinary) == 0 && - len(s.RenamedOrCopied) == 0 && - len(s.Unmerged) == 0 && - len(s.Untracked) == 0 + switch { + case s == nil: + return true + case len(s.Ignored) != 0: + return false + case len(s.Ordinary) != 0: + return false + case len(s.RenamedOrCopied) != 0: + return false + case len(s.Unmerged) != 0: + return false + case len(s.Untracked) != 0: + return false + default: + return true + } } diff --git a/internal/git/status_test.go b/internal/chezmoigit/status_test.go similarity index 77% rename from internal/git/status_test.go rename to internal/chezmoigit/status_test.go index 755f01fdbb7..a62c1e223fd 100644 --- a/internal/git/status_test.go +++ b/internal/chezmoigit/status_test.go @@ -1,10 +1,11 @@ -package git +package chezmoigit import ( "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/alecthomas/assert/v2" + + "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) func TestParseStatusPorcelainV2(t *testing.T) { @@ -15,9 +16,9 @@ func TestParseStatusPorcelainV2(t *testing.T) { expectedStatus *Status }{ { - name: "empty", - outputStr: "", - expectedEmpty: true, + name: "empty", + outputStr: "", + expectedStatus: &Status{}, }, { name: "added", @@ -57,6 +58,45 @@ func TestParseStatusPorcelainV2(t *testing.T) { }, }, }, + { + name: "copied", + outputStr: chezmoitest.JoinLines( + "2 C. N... 100644 100644 100644 4a58007052a65fbc2fc3f910f2855f45a4058e74 4a58007052a65fbc2fc3f910f2855f45a4058e74 C100 c\tb", + "2 R. N... 100644 100644 100644 4a58007052a65fbc2fc3f910f2855f45a4058e74 4a58007052a65fbc2fc3f910f2855f45a4058e74 R100 d\tb", + ), + expectedStatus: &Status{ + RenamedOrCopied: []RenamedOrCopiedStatus{ + { + X: 'C', + Y: '.', + Sub: "N...", + MH: 0o100644, + MI: 0o100644, + MW: 0o100644, + HH: "4a58007052a65fbc2fc3f910f2855f45a4058e74", + HI: "4a58007052a65fbc2fc3f910f2855f45a4058e74", + RC: 'C', + Score: 100, + Path: "c", + OrigPath: "b", + }, + { + X: 'R', + Y: '.', + Sub: "N...", + MH: 0o100644, + MI: 0o100644, + MW: 0o100644, + HH: "4a58007052a65fbc2fc3f910f2855f45a4058e74", + HI: "4a58007052a65fbc2fc3f910f2855f45a4058e74", + RC: 'R', + Score: 100, + Path: "d", + OrigPath: "b", + }, + }, + }, + }, { name: "update", outputStr: "1 .M N... 100644 100644 100644 353dbbb3c29a80fb44d4e26dac111739d25294db 353dbbb3c29a80fb44d4e26dac111739d25294db cmd/git.go\n", @@ -100,7 +140,7 @@ func TestParseStatusPorcelainV2(t *testing.T) { }, { name: "renamed_2", - outputStr: "2 R. N... 100644 100644 100644 ddbd961d7e4db2bb6615a9e8ce86364fa65e732d ddbd961d7e4db2bb6615a9e8ce86364fa65e732d R100 dot_config/chezmoi/private_chezmoi.toml\tdot_config/chezmoi/chezmoi.toml", + outputStr: "2 R. N... 100644 100644 100644 ddbd961d7e4db2bb6615a9e8ce86364fa65e732d ddbd961d7e4db2bb6615a9e8ce86364fa65e732d R100 dot_config/chezmoi/private_chezmoi.toml\tdot_config/chezmoi/chezmoi.toml", //nolint:dupword expectedStatus: &Status{ RenamedOrCopied: []RenamedOrCopiedStatus{ { @@ -185,8 +225,7 @@ func TestParseStatusPorcelainV2(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { actualStatus, err := ParseStatusPorcelainV2([]byte(tc.outputStr)) - require.NoError(t, err) - assert.Equal(t, tc.expectedEmpty, actualStatus.Empty()) + assert.NoError(t, err) assert.Equal(t, tc.expectedStatus, actualStatus) }) } diff --git a/internal/chezmoilog/chezmoilog.go b/internal/chezmoilog/chezmoilog.go index dbffa3f2857..a4076c5b3ce 100644 --- a/internal/chezmoilog/chezmoilog.go +++ b/internal/chezmoilog/chezmoilog.go @@ -2,137 +2,243 @@ package chezmoilog import ( + "context" "errors" + "fmt" + "log/slog" + "net/http" "os" "os/exec" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" + "slices" + "time" ) -// An OSExecCmdLogObject wraps an *os/exec.Cmd and adds -// github.com/rs/zerolog.LogObjectMarshaler functionality. -type OSExecCmdLogObject struct { +const few = 64 + +// An OSExecCmdLogValuer wraps an *os/exec.Cmd and adds log/slog.LogValuer +// functionality. +type OSExecCmdLogValuer 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 OSExecExitErrorLogValuer wraps an *os/exec.ExitError and adds +// log/slog.LogValuer. +type OSExecExitErrorLogValuer struct { + *exec.ExitError } -// An OSProcessStateLogObject wraps an *os.ProcessState and adds -// github.com/rs/zerolog.LogObjectMarshaler functionality. -type OSProcessStateLogObject struct { +// An OSProcessStateLogValuer wraps an *os.ProcessState and adds +// log/slog.LogValuer functionality. +type OSProcessStateLogValuer struct { *os.ProcessState } -// MarshalZerologObject implements -// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. -func (cmd OSExecCmdLogObject) MarshalZerologObject(event *zerolog.Event) { - if cmd.Cmd == nil { - return - } +// LogValuer implements log/slog.LogValuer.LogValue. +func (cmd OSExecCmdLogValuer) LogValuer() slog.Value { + var attrs []slog.Attr if cmd.Path != "" { - event.Str("path", cmd.Path) + attrs = append(attrs, slog.String("path", cmd.Path)) } - if cmd.Args != nil { - event.Strs("args", cmd.Args) + if len(cmd.Args) != 0 { + attrs = append(attrs, slog.Any("args", cmd.Args)) } if cmd.Dir != "" { - event.Str("dir", cmd.Dir) + attrs = append(attrs, slog.String("dir", cmd.Dir)) } - if cmd.Env != nil { - event.Strs("env", cmd.Env) + if len(cmd.Env) != 0 { + attrs = append(attrs, slog.Any("env", cmd.Env)) } + return slog.GroupValue(attrs...) } -// 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 +// LogValuer implements log/slog.LogValuer.LogValue. +func (err OSExecExitErrorLogValuer) LogValuer() slog.Value { + attrs := []slog.Attr{ + slog.Any("processState", OSProcessStateLogValuer{err.ExitError.ProcessState}), } - event.EmbedObject(OSProcessStateLogObject{osExecExitError.ProcessState}) - if osExecExitError.Stderr != nil { - event.Bytes("stderr", osExecExitError.Stderr) + if osExecExitError := (&exec.ExitError{}); errors.As(err, &osExecExitError) { + attrs = append(attrs, Bytes("stderr", err.ExitError.Stderr)) } + return slog.GroupValue(attrs...) } -// 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()) +// LogValue implements log/slog.LogValuer.LogValue. +func (p OSProcessStateLogValuer) LogValue() slog.Value { + var attrs []slog.Attr + if p.ProcessState != nil { + if p.Exited() { + if !p.Success() { + attrs = append(attrs, slog.Int("exitCode", p.ExitCode())) + } + } else { + attrs = append(attrs, slog.Int("pid", p.Pid())) + } + if userTime := p.UserTime(); userTime != 0 { + attrs = append(attrs, slog.Duration("userTime", userTime)) + } + if systemTime := p.SystemTime(); systemTime != 0 { + attrs = append(attrs, slog.Duration("systemTime", systemTime)) } - } else { - event.Int("pid", p.Pid()) } - if userTime := p.UserTime(); userTime != 0 { - event.Dur("userTime", userTime) + return slog.GroupValue(attrs...) +} + +func AppendExitErrorAttrs(attrs []slog.Attr, err error) []slog.Attr { + var execExitError *exec.ExitError + if !errors.As(err, &execExitError) { + return append(attrs, slog.Any("err", err)) } - if systemTime := p.SystemTime(); systemTime != 0 { - event.Dur("systemTime", systemTime) + + if execExitError.ProcessState != nil { + if execExitError.Exited() { + attrs = append(attrs, slog.Int("exitCode", execExitError.ExitCode())) + } else { + attrs = append(attrs, slog.Int("pid", execExitError.Pid())) + } + if userTime := execExitError.UserTime(); userTime != 0 { + attrs = append(attrs, slog.Duration("userTime", userTime)) + } + if systemTime := execExitError.SystemTime(); systemTime != 0 { + attrs = append(attrs, slog.Duration("systemTime", systemTime)) + } } + + return attrs } -// 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, '.', '.', '.') +// Bytes returns an slog.Attr with the value data. +func Bytes(key string, data []byte) slog.Attr { + return slog.String(key, string(data)) +} + +// FirstFewBytes returns an slog.Attr with the value of the first few bytes of +// data. +func FirstFewBytes(key string, data []byte) slog.Attr { + return slog.String(key, string(firstFewBytes(data))) +} + +// LogHTTPRequest calls httpClient.Do, logs the result to logger, and returns +// the result. +func LogHTTPRequest(ctx context.Context, logger *slog.Logger, client *http.Client, req *http.Request) (*http.Response, error) { + start := time.Now() + resp, err := client.Do(req) + attrs := []slog.Attr{ + slog.Duration("duration", time.Since(start)), + slog.String("method", req.Method), + Stringer("url", req.URL), } - return data + if resp != nil { + attrs = append(attrs, + slog.Int("statusCode", resp.StatusCode), + slog.String("status", resp.Status), + slog.Int("contentLength", int(resp.ContentLength)), + ) + } + InfoOrErrorContext(ctx, logger, "HTTPRequest", err, attrs...) + return resp, err } // LogCmdCombinedOutput calls cmd.CombinedOutput, logs the result, and returns the result. -func LogCmdCombinedOutput(cmd *exec.Cmd) ([]byte, error) { +func LogCmdCombinedOutput(logger *slog.Logger, cmd *exec.Cmd) ([]byte, error) { + start := time.Now() combinedOutput, err := cmd.CombinedOutput() - log.Err(err). - EmbedObject(OSExecCmdLogObject{Cmd: cmd}). - EmbedObject(OSExecExitErrorLogObject{Err: err}). - Bytes("combinedOutput", Output(combinedOutput, err)). - Msg("CombinedOutput") + attrs := []slog.Attr{ + slog.Any("cmd", OSExecCmdLogValuer{Cmd: cmd}), + slog.Duration("duration", time.Since(start)), + slog.Int("size", len(combinedOutput)), + slog.Any("combinedOutput", firstFewBytes(combinedOutput)), + } + attrs = AppendExitErrorAttrs(attrs, err) + InfoOrErrorContext(context.Background(), logger, "Output", err, attrs...) return combinedOutput, err } // LogCmdOutput calls cmd.Output, logs the result, and returns the result. -func LogCmdOutput(cmd *exec.Cmd) ([]byte, error) { +func LogCmdOutput(logger *slog.Logger, cmd *exec.Cmd) ([]byte, error) { + start := time.Now() output, err := cmd.Output() - log.Err(err). - EmbedObject(OSExecCmdLogObject{Cmd: cmd}). - EmbedObject(OSExecExitErrorLogObject{Err: err}). - Bytes("output", Output(output, err)). - Msg("Output") + attrs := []slog.Attr{ + slog.Any("cmd", OSExecCmdLogValuer{Cmd: cmd}), + slog.Duration("duration", time.Since(start)), + slog.Int("size", len(output)), + slog.Any("output", firstFewBytes(output)), + } + attrs = AppendExitErrorAttrs(attrs, err) + InfoOrErrorContext(context.Background(), logger, "Output", err, attrs...) return output, err } // LogCmdRun calls cmd.Run, logs the result, and returns the result. -func LogCmdRun(cmd *exec.Cmd) error { +func LogCmdRun(logger *slog.Logger, cmd *exec.Cmd) error { + start := time.Now() err := cmd.Run() - log.Err(err). - EmbedObject(OSExecCmdLogObject{Cmd: cmd}). - EmbedObject(OSExecExitErrorLogObject{Err: err}). - Msg("Run") + attrs := []slog.Attr{ + slog.Any("cmd", OSExecCmdLogValuer{Cmd: cmd}), + slog.Duration("duration", time.Since(start)), + } + attrs = AppendExitErrorAttrs(attrs, err) + InfoOrErrorContext(context.Background(), logger, "Run", err, attrs...) + return err +} + +// LogCmdStart calls cmd.Start, logs the result, and returns the result. +func LogCmdStart(logger *slog.Logger, cmd *exec.Cmd) error { + start := time.Now() + err := cmd.Start() + attrs := []slog.Attr{ + slog.Any("cmd", OSExecCmdLogValuer{Cmd: cmd}), + slog.Time("start", start), + } + attrs = AppendExitErrorAttrs(attrs, err) + InfoOrErrorContext(context.Background(), logger, "Start", err, attrs...) return err } -// Output returns the first few bytes of output if err is nil, otherwise it -// returns the full output. -func Output(data []byte, err error) []byte { +// LogCmdWait calls cmd.Wait, logs the result, and returns the result. +func LogCmdWait(logger *slog.Logger, cmd *exec.Cmd) error { + err := cmd.Wait() + end := time.Now() + attrs := []slog.Attr{ + slog.Any("cmd", OSExecCmdLogValuer{Cmd: cmd}), + slog.Time("end", end), + } + attrs = AppendExitErrorAttrs(attrs, err) + InfoOrError(logger, "Wait", err, attrs...) + return err +} + +func InfoOrError(logger *slog.Logger, msg string, err error, attrs ...slog.Attr) { + InfoOrErrorContext(context.Background(), logger, msg, err, attrs...) +} + +func InfoOrErrorContext(ctx context.Context, logger *slog.Logger, msg string, err error, attrs ...slog.Attr) { + if logger == nil { + return + } + args := make([]any, 0, len(attrs)+1) if err != nil { - return data + args = append(args, slog.Any("err", err)) } - return FirstFewBytes(data) + for _, attr := range attrs { + args = append(args, attr) + } + level := slog.LevelInfo + if err != nil { + level = slog.LevelError + } + logger.Log(ctx, level, msg, args...) +} + +// Stringer returns an slog.Attr with value. +func Stringer(key string, value fmt.Stringer) slog.Attr { + return slog.String(key, value.String()) +} + +// firstFewBytes returns the first few bytes of data. +func firstFewBytes(data []byte) []byte { + if len(data) > few { + data = slices.Clone(data[:few]) + data = append(data, '.', '.', '.') + } + return data } diff --git a/internal/chezmoilog/nullhandler.go b/internal/chezmoilog/nullhandler.go new file mode 100644 index 00000000000..4fd9f09288a --- /dev/null +++ b/internal/chezmoilog/nullhandler.go @@ -0,0 +1,14 @@ +package chezmoilog + +import ( + "context" + "log/slog" +) + +// A NullHandler implements log/slog.Handler and drops all output. +type NullHandler struct{} + +func (NullHandler) Enabled(context.Context, slog.Level) bool { return false } +func (NullHandler) Handle(context.Context, slog.Record) error { return nil } +func (h NullHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h NullHandler) WithGroup(string) slog.Handler { return h } diff --git a/internal/chezmoilog/nullhandler_test.go b/internal/chezmoilog/nullhandler_test.go new file mode 100644 index 00000000000..703f1555537 --- /dev/null +++ b/internal/chezmoilog/nullhandler_test.go @@ -0,0 +1,5 @@ +package chezmoilog + +import "log/slog" + +var _ slog.Handler = NullHandler{} diff --git a/internal/chezmoimaps/chezmoimaps.go b/internal/chezmoimaps/chezmoimaps.go new file mode 100644 index 00000000000..70eef78f6e3 --- /dev/null +++ b/internal/chezmoimaps/chezmoimaps.go @@ -0,0 +1,23 @@ +// Package chezmoimaps implements common map functions. +package chezmoimaps + +import ( + "cmp" + "slices" +) + +// Keys returns the keys of the map m. +func Keys[M ~map[K]V, K comparable, V any](m M) []K { + keys := make([]K, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + return keys +} + +// SortedKeys returns the keys of the map m in order. +func SortedKeys[M ~map[K]V, K cmp.Ordered, V any](m M) []K { + keys := Keys(m) + slices.Sort(keys) + return keys +} diff --git a/internal/chezmoiset/chezmoiset.go b/internal/chezmoiset/chezmoiset.go new file mode 100644 index 00000000000..d67fee49f33 --- /dev/null +++ b/internal/chezmoiset/chezmoiset.go @@ -0,0 +1,63 @@ +// Package chezmoiset implements a generic set type. +package chezmoiset + +// A Set is a set of elements. +type Set[T comparable] map[T]struct{} + +// New returns a new set containing elements. +func New[T comparable](elements ...T) Set[T] { + s := make(Set[T]) + s.Add(elements...) + return s +} + +// NewWithCapacity returns a new empty set with the given capacity. +func NewWithCapacity[T comparable](capacity int) Set[T] { + return make(Set[T], capacity) +} + +// Add adds elements to s. +func (s Set[T]) Add(elements ...T) { + for _, element := range elements { + s[element] = struct{}{} + } +} + +// AddSet adds all elements from other to s. +func (s Set[T]) AddSet(other Set[T]) { + for element := range other { + s[element] = struct{}{} + } +} + +// AnyElement returns an arbitrary element from s. It is typically used when s +// is known to contain exactly one element. +func (s Set[T]) AnyElement() T { + for element := range s { + return element + } + var zero T + return zero +} + +// Contains returns true if s contains element. +func (s Set[T]) Contains(element T) bool { + _, ok := s[element] + return ok +} + +// Elements returns all the elements of s. +func (s Set[T]) Elements() []T { + elements := make([]T, 0, len(s)) + for element := range s { + elements = append(elements, element) + } + return elements +} + +// Remove removes elements from s. +func (s Set[T]) Remove(elements ...T) { + for _, element := range elements { + delete(s, element) + } +} diff --git a/internal/chezmoitest/chezmoitest.go b/internal/chezmoitest/chezmoitest.go index 0b7c528c262..1a20fdfb53f 100644 --- a/internal/chezmoitest/chezmoitest.go +++ b/internal/chezmoitest/chezmoitest.go @@ -4,6 +4,8 @@ package chezmoitest import ( "fmt" "io/fs" + "log/slog" + "os" "os/exec" "regexp" "runtime" @@ -11,23 +13,20 @@ import ( "strings" "testing" - "github.com/stretchr/testify/require" - "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) -var ( - ageRecipientRx = regexp.MustCompile(`(?m)^Public key: ([0-9a-z]+)\s*$`) - gpgKeyMarkedAsUltimatelyTrustedRx = regexp.MustCompile(`(?m)^gpg: key ([0-9A-F]+) marked as ultimately trusted\s*$`) -) +var ageRecipientRx = regexp.MustCompile(`(?m)^Public key: ([0-9a-z]+)\s*$`) // AgeGenerateKey generates an identity in identityFile and returns the // recipient. -func AgeGenerateKey(identityFile string) (string, error) { - cmd := exec.Command("age-keygen", "--output", identityFile) - output, err := chezmoilog.LogCmdCombinedOutput(cmd) +func AgeGenerateKey(command, identityFile string) (string, error) { + cmd := exec.Command(command+"-keygen", "--output", identityFile) //nolint:gosec + output, err := chezmoilog.LogCmdCombinedOutput(slog.Default(), cmd) if err != nil { return "", err } @@ -41,8 +40,8 @@ func AgeGenerateKey(identityFile string) (string, error) { // GPGGenerateKey generates GPG key in homeDir and returns the key and the // passphrase. func GPGGenerateKey(command, homeDir string) (key, passphrase string, err error) { - //nolint:gosec - passphrase = "chezmoi-test-gpg-passphrase" + key = "chezmoi-test-gpg-key" + passphrase = "chezmoi-test-gpg-passphrase" //nolint:gosec cmd := exec.Command( command, "--batch", @@ -50,17 +49,12 @@ func GPGGenerateKey(command, homeDir string) (key, passphrase string, err error) "--no-tty", "--passphrase", passphrase, "--pinentry-mode", "loopback", - "--quick-generate-key", "chezmoi-test-gpg-key", + "--quick-generate-key", key, ) - output, err := chezmoilog.LogCmdCombinedOutput(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 + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = chezmoilog.LogCmdRun(slog.Default(), cmd) + return } // HomeDir returns the home directory. @@ -75,6 +69,9 @@ func HomeDir() string { // JoinLines joins lines with newlines. func JoinLines(lines ...string) string { + if len(lines) == 0 { + return "" + } return strings.Join(lines, "\n") + "\n" } @@ -90,10 +87,10 @@ func SkipUnlessGOOS(t *testing.T, name string) { } // WithTestFS calls f with a test filesystem populated with root. -func WithTestFS(t *testing.T, root interface{}, f func(vfs.FS)) { +func WithTestFS(t *testing.T, root any, f func(vfs.FS)) { t.Helper() fileSystem, cleanup, err := vfst.NewTestFS(root, vfst.BuilderUmask(Umask)) - require.NoError(t, err) + assert.NoError(t, err) t.Cleanup(cleanup) f(fileSystem) } diff --git a/internal/chezmoitest/chezmoitest_test.go b/internal/chezmoitest/chezmoitest_test.go new file mode 100644 index 00000000000..09b8e61366c --- /dev/null +++ b/internal/chezmoitest/chezmoitest_test.go @@ -0,0 +1,36 @@ +package chezmoitest + +import ( + "strconv" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestJoinLines(t *testing.T) { + for i, tc := range []struct { + lines []string + expected string + }{ + { + lines: nil, + expected: "", + }, + { + lines: []string{""}, + expected: "\n", + }, + { + lines: []string{"a"}, + expected: "a\n", + }, + { + lines: []string{"a", "b"}, + expected: "a\nb\n", + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + assert.Equal(t, tc.expected, JoinLines(tc.lines...)) + }) + } +} diff --git a/internal/chezmoitest/chezmoitest_unix.go b/internal/chezmoitest/chezmoitest_unix.go index af1d3a69b10..f1cb8a756b8 100644 --- a/internal/chezmoitest/chezmoitest_unix.go +++ b/internal/chezmoitest/chezmoitest_unix.go @@ -1,5 +1,4 @@ -//go:build !windows -// +build !windows +//go:build unix package chezmoitest @@ -10,7 +9,7 @@ import ( var ( // umaskStr is the umask used in tests represented as a string so it can be // set with the - // -ldflags="-X github.com/twpayne/chezmoi/internal/chezmoitest.umaskStr=..." + // -ldflags="-X github.com/twpayne/chezmoi/v2/internal/chezmoitest.umaskStr=..." // option to go build and go test. umaskStr = "0o022" diff --git a/internal/chezmoitest/chezmoitest_windows.go b/internal/chezmoitest/chezmoitest_windows.go index 17ab1d2fb64..65c0b2f3de0 100644 --- a/internal/chezmoitest/chezmoitest_windows.go +++ b/internal/chezmoitest/chezmoitest_windows.go @@ -3,7 +3,7 @@ package chezmoitest var ( // umaskStr is the umask used in tests represented as a string so it can be // set with the - // -ldflags="-X github.com/twpayne/chezmoi/internal/chezmoitest.umaskStr=..." + // -ldflags="-X github.com/twpayne/chezmoi/v2/internal/chezmoitest.umaskStr=..." // option to go build and go test. umaskStr = "0" diff --git a/internal/cmd/addcmd.go b/internal/cmd/addcmd.go index b4875b7d20e..881ae20a8c4 100644 --- a/internal/cmd/addcmd.go +++ b/internal/cmd/addcmd.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + "io/fs" + "os" "github.com/spf13/cobra" @@ -9,15 +11,16 @@ import ( ) type addCmdConfig struct { - TemplateSymlinks bool `mapstructure:"templateSymlinks"` + Encrypt bool `json:"encrypt" mapstructure:"encrypt" yaml:"encrypt"` + Secrets severity `json:"secrets" mapstructure:"secrets" yaml:"secrets"` + TemplateSymlinks bool `json:"templateSymlinks" mapstructure:"templateSymlinks" yaml:"templateSymlinks"` autoTemplate bool create bool - empty bool - encrypt bool exact bool - exclude *chezmoi.EntryTypeSet + filter *chezmoi.EntryTypeFilter follow bool - include *chezmoi.EntryTypeSet + prompt bool + quiet bool recursive bool template bool } @@ -31,34 +34,89 @@ func (c *Config) newAddCmd() *cobra.Command { Example: example("add"), Args: cobra.MinimumNArgs(1), RunE: c.makeRunEWithSourceState(c.runAddCmd), - Annotations: map[string]string{ - modifiesSourceDirectory: "true", - persistentStateMode: persistentStateModeReadWrite, - requiresSourceDirectory: "true", - requiresWorkingTree: "true", - }, + Annotations: newAnnotations( + createSourceDirectoryIfNeeded, + modifiesSourceDirectory, + persistentStateModeReadWrite, + requiresWorkingTree, + ), } - flags := addCmd.Flags() - flags.BoolVarP(&c.Add.autoTemplate, "autotemplate", "a", c.Add.autoTemplate, "Generate the template when adding files as templates") //nolint:lll - flags.BoolVar(&c.Add.create, "create", c.Add.create, "Add files that should exist, irrespective of their contents") - 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.BoolVar(&c.Add.exact, "exact", c.Add.exact, "Add directories exactly") - flags.VarP(c.Add.exclude, "exclude", "x", "Exclude entry types") - flags.BoolVarP(&c.Add.follow, "follow", "f", c.Add.follow, "Add symlink targets instead of symlinks") - flags.VarP(c.Add.include, "include", "i", "Include entry types") - flags.BoolVarP(&c.Add.recursive, "recursive", "r", c.Add.recursive, "Recurse into subdirectories") - flags.BoolVarP(&c.Add.template, "template", "T", c.Add.template, "Add files as templates") - flags.BoolVar(&c.Add.TemplateSymlinks, "template-symlinks", c.Add.TemplateSymlinks, "Add symlinks with target in source or home dirs as templates") //nolint:lll + addCmd.Flags(). + BoolVarP(&c.Add.autoTemplate, "autotemplate", "a", c.Add.autoTemplate, "Generate the template when adding files as templates") + addCmd.Flags().BoolVar(&c.Add.create, "create", c.Add.create, "Add files that should exist, irrespective of their contents") + addCmd.Flags().BoolVar(&c.Add.Encrypt, "encrypt", c.Add.Encrypt, "Encrypt files") + addCmd.Flags().BoolVar(&c.Add.exact, "exact", c.Add.exact, "Add directories exactly") + addCmd.Flags().VarP(c.Add.filter.Exclude, "exclude", "x", "Exclude entry types") + addCmd.Flags().BoolVarP(&c.Add.follow, "follow", "f", c.Add.follow, "Add symlink targets instead of symlinks") + addCmd.Flags().VarP(c.Add.filter.Include, "include", "i", "Include entry types") + addCmd.Flags().BoolVarP(&c.Add.prompt, "prompt", "p", c.Add.prompt, "Prompt before adding each entry") + addCmd.Flags().BoolVarP(&c.Add.quiet, "quiet", "q", c.Add.quiet, "Suppress warnings") + addCmd.Flags().BoolVarP(&c.Add.recursive, "recursive", "r", c.Add.recursive, "Recurse into subdirectories") + addCmd.Flags().Var(&c.Add.Secrets, "secrets", "Scan for secrets when adding unencrypted files") + addCmd.Flags().BoolVarP(&c.Add.template, "template", "T", c.Add.template, "Add files as templates") + addCmd.Flags(). + BoolVar(&c.Add.TemplateSymlinks, "template-symlinks", c.Add.TemplateSymlinks, "Add symlinks with target in source or home dirs as templates") return addCmd } -// defaultPreAddFunc prompts the user for confirmation if the adding the entry +func (c *Config) defaultOnIgnoreFunc(targetRelPath chezmoi.RelPath) { + if !c.Add.quiet { + c.errorf("warning: ignoring %s\n", targetRelPath) + } +} + +func (c *Config) defaultPreAddFunc(targetRelPath chezmoi.RelPath, fileInfo fs.FileInfo) error { + // Scan unencrypted files for secrets, if configured. + if c.Add.Secrets != severityIgnore && fileInfo.Mode().Type() == 0 && !c.Add.Encrypt { + absPath := c.DestDirAbsPath.Join(targetRelPath) + content, err := c.destSystem.ReadFile(absPath) + if err != nil { + return err + } + gitleaksDetector, err := c.getGitleaksDetector() + if err != nil { + return err + } + findings := gitleaksDetector.DetectBytes(content) + for _, finding := range findings { + c.errorf("%s:%d: %s\n", absPath, finding.StartLine+1, finding.Description) + } + if !c.force && c.Add.Secrets == severityError && len(findings) > 0 { + return chezmoi.ExitCodeError(1) + } + } + + if !c.Add.prompt { + return nil + } + + prompt := fmt.Sprintf("add %s", c.SourceDirAbsPath.Join(targetRelPath)) + for { + switch choice, err := c.promptChoice(prompt, choicesYesNoAllQuit); { + case err != nil: + return err + case choice == "all": + c.Add.prompt = false + return nil + case choice == "no": + return fs.SkipDir + case choice == "quit": + return chezmoi.ExitCodeError(0) + case choice == "yes": + return nil + default: + panic(choice + ": unexpected choice") + } + } +} + +// defaultReplaceFunc prompts the user for confirmation if the adding the entry // would remove any of the encrypted, private, or template attributes. -func (c *Config) defaultPreAddFunc( - targetRelPath chezmoi.RelPath, newSourceStateEntry, oldSourceStateEntry chezmoi.SourceStateEntry, +func (c *Config) defaultReplaceFunc( + targetRelPath chezmoi.RelPath, + newSourceStateEntry, oldSourceStateEntry chezmoi.SourceStateEntry, ) error { if c.force { return nil @@ -94,36 +152,64 @@ func (c *Config) defaultPreAddFunc( c.force = true return nil case choice == "no": - return chezmoi.Skip + return fs.SkipDir case choice == "quit": - return ExitCodeError(1) + return chezmoi.ExitCodeError(0) case choice == "yes": return nil default: - return nil + panic(choice + ": unexpected choice") } } } func (c *Config) runAddCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { destAbsPathInfos, err := c.destAbsPathInfos(sourceState, args, destAbsPathInfosOptions{ - follow: c.Mode == chezmoi.ModeSymlink || c.Add.follow, - recursive: c.Add.recursive, + follow: c.Mode == chezmoi.ModeSymlink || c.Add.follow, + onIgnoreFunc: c.defaultOnIgnoreFunc, + recursive: c.Add.recursive, }) if err != nil { return err } - return sourceState.Add(c.sourceSystem, c.persistentState, c.destSystem, destAbsPathInfos, &chezmoi.AddOptions{ - AutoTemplate: c.Add.autoTemplate, - Create: c.Add.create, - Empty: c.Add.empty, - Encrypt: c.Add.encrypt, - EncryptedSuffix: c.encryption.EncryptedSuffix(), - Exact: c.Add.exact, - Include: c.Add.include.Sub(c.Add.exclude), - PreAddFunc: c.defaultPreAddFunc, - Template: c.Add.template, - TemplateSymlinks: c.Add.TemplateSymlinks, - }) + persistentStateFileAbsPath, err := c.persistentStateFile() + if err != nil { + return err + } + + executable, err := os.Executable() + if err != nil { + return err + } + + return sourceState.Add( + c.sourceSystem, + c.persistentState, + c.destSystem, + destAbsPathInfos, + &chezmoi.AddOptions{ + AutoTemplate: c.Add.autoTemplate, + Create: c.Add.create, + Encrypt: c.Add.Encrypt, + EncryptedSuffix: c.encryption.EncryptedSuffix(), + Exact: c.Add.exact, + Errorf: c.errorf, + Filter: c.Add.filter, + OnIgnoreFunc: c.defaultOnIgnoreFunc, + PreAddFunc: c.defaultPreAddFunc, + ConfigFileAbsPath: c.getConfigFileAbsPath(), + ProtectedAbsPaths: []chezmoi.AbsPath{ + c.CacheDirAbsPath, + c.WorkingTreeAbsPath, + c.getConfigFileAbsPath().Dir(), + persistentStateFileAbsPath, + c.sourceDirAbsPath, + chezmoi.NewAbsPath(executable), + }, + ReplaceFunc: c.defaultReplaceFunc, + Template: c.Add.template, + TemplateSymlinks: c.Add.TemplateSymlinks, + }, + ) } diff --git a/internal/cmd/addcmd_test.go b/internal/cmd/addcmd_test.go index 23fc0b909b0..fbc7a2c61de 100644 --- a/internal/cmd/addcmd_test.go +++ b/internal/cmd/addcmd_test.go @@ -1,11 +1,13 @@ package cmd import ( + "io/fs" + "runtime" "testing" - "github.com/stretchr/testify/require" - "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -13,22 +15,22 @@ import ( func TestAddCmd(t *testing.T) { for _, tc := range []struct { name string - root interface{} + root any args []string - tests []interface{} + tests []any }{ { name: "dir", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ - ".dir": &vfst.Dir{Perm: 0o777}, + root: map[string]any{ + "/home/user": map[string]any{ + ".dir": &vfst.Dir{Perm: fs.ModePerm}, }, }, args: []string{"~/.dir"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/.keep", vfst.TestContents(nil), @@ -38,27 +40,27 @@ func TestAddCmd(t *testing.T) { }, { name: "dir_with_file", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ ".dir": &vfst.Dir{ - Perm: 0o777, - Entries: map[string]interface{}{ + Perm: fs.ModePerm, + Entries: map[string]any{ "file": "# contents of .dir/file\n", }, }, }, }, args: []string{"~/.dir"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/.keep", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .dir/file\n"), ), @@ -66,54 +68,54 @@ func TestAddCmd(t *testing.T) { }, { name: "dir_with_file_with_--recursive=false", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ ".dir": &vfst.Dir{ - Perm: 0o777, - Entries: map[string]interface{}{ + Perm: fs.ModePerm, + Entries: map[string]any{ "file": "# contents of .dir/file\n", }, }, }, }, args: []string{"~/.dir", "--recursive=false"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/.keep", vfst.TestContents(nil), vfst.TestModePerm(0o666&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/file", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), }, }, { name: "dir_private_unix", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ ".dir": &vfst.Dir{ Perm: 0o700, - Entries: map[string]interface{}{ + Entries: map[string]any{ "file": "# contents of .dir/file\n", }, }, }, }, args: []string{"~/.dir"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_dir/.keep", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_dir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .dir/file\n"), ), @@ -121,27 +123,27 @@ func TestAddCmd(t *testing.T) { }, { name: "dir_file_private_unix", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ ".dir": &vfst.Dir{ Perm: 0o700, - Entries: map[string]interface{}{ + Entries: map[string]any{ "file": "# contents of .dir/file\n", }, }, }, }, args: []string{"~/.dir/file"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_dir/.keep", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.local/share/chezmoi/private_dot_dir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .dir/file\n"), ), @@ -149,29 +151,15 @@ func TestAddCmd(t *testing.T) { }, { name: "empty", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ ".empty": "", }, }, args: []string{"~/.empty"}, - tests: []interface{}{ + tests: []any{ 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.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContents(nil), ), @@ -179,18 +167,18 @@ func TestAddCmd(t *testing.T) { }, { name: "executable_unix", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ ".executable": &vfst.File{ - Perm: 0o777, + Perm: fs.ModePerm, Contents: []byte("#!/bin/sh\n"), }, }, }, args: []string{"~/.executable"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/executable_dot_executable", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("#!/bin/sh\n"), ), @@ -198,15 +186,15 @@ func TestAddCmd(t *testing.T) { }, { name: "file", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ ".file": "# contents of .file\n", }, }, args: []string{"~/.file"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .file\n"), ), @@ -214,17 +202,17 @@ func TestAddCmd(t *testing.T) { }, { name: "symlink", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ ".symlink": &vfst.Symlink{ Target: ".dir/subdir/file", }, }, }, args: []string{"~/.symlink"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/symlink_dot_symlink", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString(".dir/subdir/file\n"), ), @@ -232,8 +220,8 @@ func TestAddCmd(t *testing.T) { }, { name: "symlink_with_--follow", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ ".file": "# contents of .file\n", ".symlink": &vfst.Symlink{ Target: ".file", @@ -241,21 +229,66 @@ func TestAddCmd(t *testing.T) { }, }, args: []string{"--follow", "~/.symlink"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_symlink", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .file\n"), ), }, }, + { + name: "issue_3666", + root: map[string]any{ + "/home/user": map[string]any{ + ".config/helix/themes/ayu_custom.toml": "# contents of ayu_custom.toml\n", + ".local/share/chezmoi": map[string]any{ + "dot_config/exact_helix": &vfst.Dir{Perm: 0o777 &^ chezmoitest.Umask}, + }, + }, + }, + args: []string{"~/.config/helix/themes/ayu_custom.toml"}, + tests: []any{ + vfst.TestPath("/home/user/.local/share/chezmoi/dot_config/exact_helix/themes/ayu_custom.toml", + vfst.TestModeIsRegular(), + vfst.TestModePerm(0o666&^chezmoitest.Umask), + vfst.TestContentsString("# contents of ayu_custom.toml\n"), + ), + }, + }, } { t.Run(tc.name, func(t *testing.T) { chezmoitest.SkipUnlessGOOS(t, tc.name) chezmoitest.WithTestFS(t, tc.root, func(fileSystem vfs.FS) { - require.NoError(t, newTestConfig(t, fileSystem).execute(append([]string{"add"}, tc.args...))) + assert.NoError(t, newTestConfig(t, fileSystem).execute(append([]string{"add"}, tc.args...))) vfst.RunTests(t, fileSystem, "", tc.tests...) }) }) } } + +func TestAddCmdChmod(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping UNIX test on Windows") + } + + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user": map[string]any{ + ".dir/subdir/file": "# contents of .dir/subdir/file\n", + }, + }, func(fileSystem vfs.FS) { + assert.NoError(t, newTestConfig(t, fileSystem).execute([]string{"add", "/home/user/.dir"})) + assert.NoError(t, fileSystem.Chmod("/home/user/.dir/subdir", 0o700)) + assert.NoError(t, newTestConfig(t, fileSystem).execute([]string{"add", "--force", "/home/user/.dir"})) + }) +} + +func TestAddCmdSecretsError(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user": map[string]any{ + ".secret": "AWS_ACCESS_KEY_ID=AKIA0000000000000000\n", + }, + }, func(fileSystem vfs.FS) { + assert.Error(t, newTestConfig(t, fileSystem).execute([]string{"add", "--secrets=error", "/home/user/.secret"})) + }) +} diff --git a/internal/cmd/agecmd.go b/internal/cmd/agecmd.go new file mode 100644 index 00000000000..a31831a75c4 --- /dev/null +++ b/internal/cmd/agecmd.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "bytes" + "errors" + "io" + + "filippo.io/age" + "filippo.io/age/armor" + "github.com/spf13/cobra" +) + +type ageDecryptCmdConfig struct { + passphrase bool +} + +type ageEncryptCmdConfig struct { + passphrase bool +} + +type ageCmdConfig struct { + decrypt ageDecryptCmdConfig + encrypt ageEncryptCmdConfig +} + +func (c *Config) newAgeCmd() *cobra.Command { + ageCmd := &cobra.Command{ + Use: "age", + Args: cobra.NoArgs, + Short: "Interact with age", + } + + ageDecryptCmd := &cobra.Command{ + Use: "decrypt [file...]", + Short: "Decrypt file or standard input", + RunE: c.runAgeDecryptCmd, + Annotations: newAnnotations(), + } + ageDecryptCmd.Flags(). + BoolVarP(&c.age.decrypt.passphrase, "passphrase", "p", c.age.decrypt.passphrase, "Decrypt with a passphrase") + ageCmd.AddCommand(ageDecryptCmd) + + ageEncryptCmd := &cobra.Command{ + Use: "encrypt [file...]", + Short: "Encrypt file or standard input", + RunE: c.runAgeEncryptCmd, + Annotations: newAnnotations(), + } + ageEncryptCmd.Flags(). + BoolVarP(&c.age.encrypt.passphrase, "passphrase", "p", c.age.encrypt.passphrase, "Encrypt with a passphrase") + ageCmd.AddCommand(ageEncryptCmd) + + return ageCmd +} + +func (c *Config) runAgeDecryptCmd(cmd *cobra.Command, args []string) error { + if !c.age.decrypt.passphrase { + return errors.New("only passphrase encryption is supported") + } + decrypt := func(ciphertext []byte) ([]byte, error) { + var ciphertextReader io.Reader = bytes.NewReader(ciphertext) + if bytes.HasPrefix(ciphertext, []byte(armor.Header)) { + ciphertextReader = armor.NewReader(ciphertextReader) + } + identity := &LazyScryptIdentity{ + Passphrase: func() (string, error) { + return c.readPassword("Enter passphrase: ") + }, + } + plaintextReader, err := age.Decrypt(ciphertextReader, identity) + if err != nil { + return nil, err + } + plaintextBuffer := &bytes.Buffer{} + if _, err := io.Copy(plaintextBuffer, plaintextReader); err != nil { + return nil, err + } + return plaintextBuffer.Bytes(), nil + } + return c.filterInput(args, decrypt) +} + +func (c *Config) runAgeEncryptCmd(cmd *cobra.Command, args []string) error { + if !c.age.encrypt.passphrase { + return errors.New("only passphrase encryption is supported") + } + passphrase, err := c.readPassword("Enter passphrase: ") + if err != nil { + return err + } + confirmPassphrase, err := c.readPassword("Confirm passphrase: ") + if err != nil { + return err + } + if passphrase != confirmPassphrase { + return errors.New("passphrases didn't match") + } + recipient, err := age.NewScryptRecipient(passphrase) + if err != nil { + return err + } + encrypt := func(plaintext []byte) ([]byte, error) { + ciphertextBuffer := &bytes.Buffer{} + armoredCiphertextWriter := armor.NewWriter(ciphertextBuffer) + ciphertextWriteCloser, err := age.Encrypt(armoredCiphertextWriter, recipient) + if err != nil { + return nil, err + } + if _, err := io.Copy(ciphertextWriteCloser, bytes.NewReader(plaintext)); err != nil { + return nil, err + } + if err := ciphertextWriteCloser.Close(); err != nil { + return nil, err + } + if err := armoredCiphertextWriter.Close(); err != nil { + return nil, err + } + return ciphertextBuffer.Bytes(), nil + } + return c.filterInput(args, encrypt) +} diff --git a/internal/cmd/annotation.go b/internal/cmd/annotation.go new file mode 100644 index 00000000000..64b63e13808 --- /dev/null +++ b/internal/cmd/annotation.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + "slices" + + "github.com/spf13/cobra" +) + +// Annotations. +var ( + createSourceDirectoryIfNeeded = tagAnnotation("chezmoi_create_source_directory_if_needed") + doesNotRequireValidConfig = tagAnnotation("chezmoi_runs_with_invalid_config") + dryRun = tagAnnotation("chezmoi_dry_run") + modifiesConfigFile = tagAnnotation("chezmoi_modifies_config_file") + modifiesDestinationDirectory = tagAnnotation("chezmoi_modifies_destination_directory") + modifiesSourceDirectory = tagAnnotation("chezmoi_modifies_source_directory") + outputsDiff = tagAnnotation("chezmoi_outputs_diff") + persistentStateModeKey = tagAnnotation("chezmoi_persistent_state_mode") + requiresConfigDirectory = tagAnnotation("chezmoi_requires_config_directory") + requiresSourceDirectory = tagAnnotation("chezmoi_requires_source_directory") + requiresWorkingTree = tagAnnotation("chezmoi_requires_working_tree") + runsCommands = tagAnnotation("chezmoi_runs_commands") +) + +// Persistent state modes. +const ( + persistentStateModeEmpty persistentStateModeValue = "empty" + persistentStateModeReadOnly persistentStateModeValue = "read-only" + persistentStateModeReadMockWrite persistentStateModeValue = "read-mock-write" + persistentStateModeReadWrite persistentStateModeValue = "read-write" +) + +type annotation interface { + key() string + value() string +} + +type annotationsSet map[string]string + +func getAnnotations(cmd *cobra.Command) annotationsSet { + thirdPartyCommandNames := []string{ + "__complete", + } + if cmd.Annotations == nil && !slices.Contains(thirdPartyCommandNames, cmd.Name()) { + panic(fmt.Sprintf("%q: no annotations", cmd.Name())) + } + return annotationsSet(cmd.Annotations) +} + +func newAnnotations(annotations ...annotation) annotationsSet { + result := make(map[string]string, len(annotations)) + for _, annotation := range annotations { + result[annotation.key()] = annotation.value() + } + return result +} + +func (a annotationsSet) hasTag(tag annotation) bool { + return a[tag.key()] == tag.value() +} + +func (a annotationsSet) persistentStateMode() persistentStateModeValue { + return persistentStateModeValue(a[string(persistentStateModeKey)]) +} + +type persistentStateModeValue string + +func (m persistentStateModeValue) key() string { + return string(persistentStateModeKey) +} + +func (m persistentStateModeValue) value() string { + return string(m) +} + +type tagAnnotation string + +func (a tagAnnotation) key() string { + return string(a) +} + +func (a tagAnnotation) value() string { + return "true" +} diff --git a/internal/cmd/applycmd.go b/internal/cmd/applycmd.go index 5e9a7733723..ff1a87c748b 100644 --- a/internal/cmd/applycmd.go +++ b/internal/cmd/applycmd.go @@ -7,37 +7,38 @@ import ( ) type applyCmdConfig struct { - exclude *chezmoi.EntryTypeSet + filter *chezmoi.EntryTypeFilter init bool - include *chezmoi.EntryTypeSet 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, - }, + Use: "apply [target]...", + Short: "Update the destination directory to match the target state", + Long: mustLongHelp("apply"), + Example: example("apply"), + ValidArgsFunction: c.targetValidArgs, + RunE: c.runApplyCmd, + Annotations: newAnnotations( + modifiesDestinationDirectory, + persistentStateModeReadWrite, + requiresSourceDirectory, + ), } - flags := applyCmd.Flags() - flags.VarP(c.apply.exclude, "exclude", "x", "Exclude entry types") - flags.VarP(c.apply.include, "include", "i", "Include entry types") - flags.BoolVar(&c.apply.init, "init", c.update.init, "Recreate config file from template") - flags.BoolVarP(&c.apply.recursive, "recursive", "r", c.apply.recursive, "Recurse into subdirectories") + applyCmd.Flags().VarP(c.apply.filter.Exclude, "exclude", "x", "Exclude entry types") + applyCmd.Flags().VarP(c.apply.filter.Include, "include", "i", "Include entry types") + applyCmd.Flags().BoolVar(&c.apply.init, "init", c.apply.init, "Recreate config file from template") + applyCmd.Flags().BoolVarP(&c.apply.recursive, "recursive", "r", c.apply.recursive, "Recurse into subdirectories") return applyCmd } func (c *Config) runApplyCmd(cmd *cobra.Command, args []string) error { return c.applyArgs(cmd.Context(), c.destSystem, c.DestDirAbsPath, args, applyArgsOptions{ - include: c.apply.include.Sub(c.apply.exclude), + cmd: cmd, + filter: c.apply.filter, init: c.apply.init, recursive: c.apply.recursive, umask: c.Umask, diff --git a/internal/cmd/applycmd_test.go b/internal/cmd/applycmd_test.go index 1eece386445..bf06101192b 100644 --- a/internal/cmd/applycmd_test.go +++ b/internal/cmd/applycmd_test.go @@ -2,12 +2,14 @@ package cmd import ( "io/fs" + "net/http" + "net/http/httptest" "path/filepath" "testing" - "github.com/stretchr/testify/require" - "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -15,65 +17,65 @@ import ( func TestApplyCmd(t *testing.T) { for _, tc := range []struct { name string - extraRoot interface{} + extraRoot any args []string - tests []interface{} + tests []any }{ { name: "all", - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.create", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .create\n"), ), vfst.TestPath("/home/user/.dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.dir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .dir/file\n"), ), vfst.TestPath("/home/user/.dir/subdir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.dir/subdir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .dir/subdir/file\n"), ), vfst.TestPath("/home/user/.empty", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContents(nil), ), vfst.TestPath("/home/user/.executable", - vfst.TestModeIsRegular, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestModeIsRegular(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), vfst.TestContentsString("# contents of .executable\n"), ), vfst.TestPath("/home/user/.file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .file\n"), ), vfst.TestPath("/home/user/.private", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o600&^chezmoitest.Umask), vfst.TestContentsString("# contents of .private\n"), ), vfst.TestPath("/home/user/.remove", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.symlink", vfst.TestModeType(fs.ModeSymlink), vfst.TestSymlinkTarget(filepath.FromSlash(".dir/subdir/file")), ), vfst.TestPath("/home/user/.template", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("key = value\n"), ), @@ -82,55 +84,55 @@ func TestApplyCmd(t *testing.T) { { name: "all_with_--dry-run", args: []string{"--dry-run"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.create", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.dir", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.empty", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.executable", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.file", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.private", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.remove", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.symlink", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.template", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), }, }, { name: "dir", args: []string{"~/.dir"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.dir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .dir/file\n"), ), vfst.TestPath("/home/user/.dir/subdir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.dir/subdir/file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .dir/subdir/file\n"), ), @@ -139,28 +141,28 @@ func TestApplyCmd(t *testing.T) { { name: "dir_with_--recursive=false", args: []string{"~/.dir", "--recursive=false"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.dir/file", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.dir/subdir", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), }, }, { name: "create", args: []string{"~/.create"}, - extraRoot: map[string]interface{}{ + extraRoot: map[string]any{ "/home/user/.create": "# existing contents of .create\n", }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.create", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# existing contents of .create\n"), ), @@ -168,23 +170,23 @@ func TestApplyCmd(t *testing.T) { }, } { 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{}{ + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user": map[string]any{ + ".config": map[string]any{ + "chezmoi": map[string]any{ "chezmoi.toml": chezmoitest.JoinLines( `[data]`, ` variable = "value"`, ), }, }, - ".local": map[string]interface{}{ - "share": map[string]interface{}{ - "chezmoi": map[string]interface{}{ + ".local": map[string]any{ + "share": map[string]any{ + "chezmoi": map[string]any{ "create_dot_create": "# contents of .create\n", - "dot_dir": map[string]interface{}{ + "dot_dir": map[string]any{ "file": "# contents of .dir/file\n", - "subdir": map[string]interface{}{ + "subdir": map[string]any{ "file": "# contents of .dir/subdir/file\n", }, }, @@ -203,11 +205,121 @@ func TestApplyCmd(t *testing.T) { }, }, func(fileSystem vfs.FS) { if tc.extraRoot != nil { - require.NoError(t, vfst.NewBuilder().Build(fileSystem, tc.extraRoot)) + assert.NoError(t, vfst.NewBuilder().Build(fileSystem, tc.extraRoot)) } - require.NoError(t, newTestConfig(t, fileSystem).execute(append([]string{"apply"}, tc.args...))) + assert.NoError(t, newTestConfig(t, fileSystem).execute(append([]string{"apply"}, tc.args...))) vfst.RunTests(t, fileSystem, "", tc.tests) }) }) } } + +func TestIssue2132(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]interface{}{ + "/home/user/.local/share/chezmoi/remove_dot_dir/non_existent_file": "", + }, func(fileSystem vfs.FS) { + config1 := newTestConfig(t, fileSystem) + assert.NoError(t, config1.execute([]string{"apply"})) + vfst.RunTests(t, fileSystem, "", + vfst.TestPath("/home/user/.dir", + vfst.TestDoesNotExist(), + ), + ) + config2 := newTestConfig(t, fileSystem) + assert.NoError(t, config2.execute([]string{"apply", "--no-tty"})) + vfst.RunTests(t, fileSystem, "", + vfst.TestPath("/home/user/.dir", + vfst.TestDoesNotExist(), + ), + ) + }) +} + +func TestIssue2597(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": map[string]any{ + ".chezmoiexternal.toml": chezmoitest.JoinLines( + `[".oh-my-zsh"]`, + ` type = "archive"`, + ` url = "https://github.com/ohmyzsh/ohmyzsh/archive/master.tar.gz"`, + ` exact = true`, + ` stripComponents = 1`, + ), + ".chezmoiignore": chezmoitest.JoinLines( + `.oh-my-zsh/cache`, + ), + }, + }, + }, func(fileSystem vfs.FS) { + assert.NoError(t, newTestConfig(t, fileSystem).execute([]string{"apply"})) + }) +} + +func TestIssue3206(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": map[string]any{ + ".chezmoiignore": "", + "dot_config/private_expanso/match": map[string]any{ + ".chezmoidata.yaml": "key: value\n", + "greek.yml.tmpl": "{{ .key }}", + }, + }, + }, + }, func(fileSystem vfs.FS) { + assert.NoError(t, newTestConfig(t, fileSystem).execute([]string{"apply"})) + }) +} + +func TestIssue3216(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi": map[string]any{ + ".chezmoiignore": "", + "dot_config/private_expanso/match": map[string]any{ + ".chezmoidata.yaml": "", + "greek.yml.tmpl": "{{ .chezmoi.os }}", + }, + }, + }, + }, func(fileSystem vfs.FS) { + assert.NoError(t, newTestConfig(t, fileSystem).execute([]string{"apply"})) + }) +} + +func TestIssue3703(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte("contents of file\n")) + assert.NoError(t, err) + })) + defer httpServer.Close() + + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user": map[string]any{ + ".local": map[string]any{ + "bin": map[string]any{ + "unmanaged": "", + }, + "share/chezmoi": map[string]any{ + ".chezmoiexternal.toml.tmpl": chezmoitest.JoinLines( + `[".local/bin/file"]`, + ` type = "file"`, + ` url = "`+httpServer.URL+`/file"`, + ), + "dot_local/exact_bin/.keep": "", + }, + }, + }, + }, func(fileSystem vfs.FS) { + assert.NoError(t, newTestConfig(t, fileSystem).execute([]string{"apply"})) + vfst.RunTests(t, fileSystem, ".local/bin", + vfst.TestPath("/home/user/.local/bin/file", + vfst.TestContentsString("contents of file\n"), + ), + vfst.TestPath("/home/user/.local/bin/unmanaged", + vfst.TestDoesNotExist(), + ), + ) + }) +} diff --git a/internal/cmd/archivecmd.go b/internal/cmd/archivecmd.go index 6715b86b3ad..2fc70b379d5 100644 --- a/internal/cmd/archivecmd.go +++ b/internal/cmd/archivecmd.go @@ -2,45 +2,45 @@ package cmd import ( "archive/tar" - "compress/gzip" "os/user" "strconv" "strings" "time" + "github.com/klauspost/compress/gzip" "github.com/spf13/cobra" "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) type archiveCmdConfig struct { - exclude *chezmoi.EntryTypeSet + filter *chezmoi.EntryTypeFilter format chezmoi.ArchiveFormat gzip bool - include *chezmoi.EntryTypeSet init bool 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, - }, + Use: "archive [target]...", + Short: "Generate a tar archive of the target state", + Long: mustLongHelp("archive"), + Example: example("archive"), + ValidArgsFunction: c.targetValidArgs, + RunE: c.runArchiveCmd, + Annotations: newAnnotations( + persistentStateModeEmpty, + requiresSourceDirectory, + ), } - flags := archiveCmd.Flags() - flags.VarP(c.archive.exclude, "exclude", "x", "Exclude entry types") - flags.VarP(&c.archive.format, "format", "f", "Set archive format") - flags.BoolVarP(&c.archive.gzip, "gzip", "z", c.archive.gzip, "Compress output with gzip") - flags.VarP(c.archive.include, "include", "i", "Include entry types") - flags.BoolVar(&c.archive.init, "init", c.update.init, "Recreate config file from template") - flags.BoolVarP(&c.archive.recursive, "recursive", "r", c.archive.recursive, "Recurse into subdirectories") + archiveCmd.Flags().VarP(c.archive.filter.Exclude, "exclude", "x", "Exclude entry types") + archiveCmd.Flags().VarP(&c.archive.format, "format", "f", "Set archive format") + archiveCmd.Flags().BoolVarP(&c.archive.gzip, "gzip", "z", c.archive.gzip, "Compress output with gzip") + archiveCmd.Flags().VarP(c.archive.filter.Exclude, "include", "i", "Include entry types") + archiveCmd.Flags().BoolVar(&c.archive.init, "init", c.archive.init, "Recreate config file from template") + archiveCmd.Flags().BoolVarP(&c.archive.recursive, "recursive", "r", c.archive.recursive, "Recurse into subdirectories") return archiveCmd } @@ -66,14 +66,15 @@ func (c *Config) runArchiveCmd(cmd *cobra.Command, args []string) error { } switch format { case chezmoi.ArchiveFormatTar, chezmoi.ArchiveFormatTarGz, chezmoi.ArchiveFormatTgz: - archiveSystem = chezmoi.NewTARWriterSystem(&output, tarHeaderTemplate()) + archiveSystem = chezmoi.NewTarWriterSystem(&output, tarHeaderTemplate()) case chezmoi.ArchiveFormatZip: archiveSystem = chezmoi.NewZIPWriterSystem(&output, time.Now().UTC()) default: - return chezmoi.InvalidArchiveFormatError(format) + return chezmoi.UnknownArchiveFormatError(format) } if err := c.applyArgs(cmd.Context(), archiveSystem, chezmoi.EmptyAbsPath, args, applyArgsOptions{ - include: c.archive.include.Sub(c.archive.exclude), + cmd: cmd, + filter: c.archive.filter, init: c.archive.init, recursive: c.archive.recursive, }); err != nil { diff --git a/internal/cmd/autobool.go b/internal/cmd/autobool.go index 0a31b0a10b0..66ea9dfa9f0 100644 --- a/internal/cmd/autobool.go +++ b/internal/cmd/autobool.go @@ -1,12 +1,16 @@ package cmd import ( + "errors" "fmt" "reflect" "strconv" "strings" "github.com/mitchellh/mapstructure" + "gopkg.in/yaml.v3" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) type autoBool struct { @@ -14,15 +18,43 @@ type autoBool struct { value bool } +// autoBoolFlagCompletionFunc is a function that completes the value of autoBool +// flags. +var autoBoolFlagCompletionFunc = chezmoi.FlagCompletionFunc([]string{ + "1", "t", "T", "true", "TRUE", "True", + "0", "f", "F", "false", "FALSE", "False", + "auto", "AUTO", "Auto", +}) + +// MarshalJSON implements encoding/json.Marshaler.MarshalJSON. +func (b autoBool) MarshalJSON() ([]byte, error) { + switch { + case b.auto: + return []byte(`"auto"`), nil + case b.value: + return []byte(`true`), nil + default: + return []byte(`false`), nil + } +} + +// MarshalYAML implements gopkg.in/yaml.v3.Marshaler. +func (b autoBool) MarshalYAML() (any, error) { + if b.auto { + return "auto", nil + } + return b.value, nil +} + // Set implements github.com/spf13/pflag.Value.Set. func (b *autoBool) Set(s string) error { - if strings.ToLower(s) == "auto" { + if strings.EqualFold(s, "auto") { b.auto = true return nil } b.auto = false var err error - b.value, err = parseBool(s) + b.value, err = chezmoi.ParseBool(s) if err != nil { return err } @@ -41,6 +73,39 @@ func (b *autoBool) Type() string { return "bool|auto" } +// UnmarshalJSON implements encoding/json.Unmarshaler.UnmarshalJSON. +func (b *autoBool) UnmarshalJSON(data []byte) error { + if string(data) == `"auto"` { + b.auto = true + return nil + } + value, err := chezmoi.ParseBool(string(data)) + if err != nil { + return err + } + b.auto = false + b.value = value + return nil +} + +// UnmarshalYAML implements gopkg.in/yaml.Unmarshaler.UnmarshalYAML. +func (b *autoBool) UnmarshalYAML(value *yaml.Node) error { + if value.Kind != yaml.ScalarNode { + return errors.New("expected scalar node") + } + if value.Value == "auto" { + b.auto = true + return nil + } + boolValue, err := chezmoi.ParseBool(value.Value) + if err != nil { + return err + } + b.auto = false + b.value = boolValue + return nil +} + // Value returns b's value, calling b's autoFunc if needed. func (b *autoBool) Value(autoFunc func() bool) bool { if b.auto { @@ -54,7 +119,7 @@ func (b *autoBool) Value(autoFunc func() bool) bool { // github.com/mitchellh/mapstructure.DecodeHookFunc that parses an autoBool from // a bool or string. func StringOrBoolToAutoBoolHookFunc() mapstructure.DecodeHookFunc { - return func(from, to reflect.Type, data interface{}) (interface{}, error) { + return func(from, to reflect.Type, data any) (any, error) { if to != reflect.TypeOf(autoBool{}) { return data, nil } diff --git a/internal/cmd/awssecretsmanagertemplatefuncs.go b/internal/cmd/awssecretsmanagertemplatefuncs.go new file mode 100644 index 00000000000..871026574e3 --- /dev/null +++ b/internal/cmd/awssecretsmanagertemplatefuncs.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" +) + +type awsSecretsManagerConfig struct { + Region string `json:"region" mapstructure:"region" yaml:"region"` + Profile string `json:"profile" mapstructure:"profile" yaml:"profile"` + svc *secretsmanager.Client + cache map[string]string + jsonCache map[string]map[string]any +} + +func (c *Config) awsSecretsManagerRawTemplateFunc(arn string) string { + if secret, ok := c.AWSSecretsManager.cache[arn]; ok { + return secret + } + + if c.AWSSecretsManager.svc == nil { + var opts []func(*config.LoadOptions) error + if region := c.AWSSecretsManager.Region; len(region) > 0 { + opts = append(opts, config.WithRegion(region)) + } + if profile := c.AWSSecretsManager.Profile; len(profile) > 0 { + opts = append(opts, config.WithSharedConfigProfile(profile)) + } + + opts = append(opts, config.WithRetryMaxAttempts(1)) + + cfg, err := config.LoadDefaultConfig(context.Background(), opts...) + if err != nil { + panic(err) + } + + c.AWSSecretsManager.svc = secretsmanager.NewFromConfig(cfg) + } + + result, err := c.AWSSecretsManager.svc.GetSecretValue(context.Background(), &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(arn), + }) + if err != nil { + panic(err) + } + + var secret string + if result.SecretString != nil { + secret = *result.SecretString + } else { + decodedBinarySecretBytes := make([]byte, base64.StdEncoding.DecodedLen(len(result.SecretBinary))) + length, err := base64.StdEncoding.Decode(decodedBinarySecretBytes, result.SecretBinary) + if err != nil { + panic(err) + } + + secret = string(decodedBinarySecretBytes[:length]) + } + + if c.AWSSecretsManager.cache == nil { + c.AWSSecretsManager.cache = make(map[string]string) + } + + c.AWSSecretsManager.cache[arn] = secret + return secret +} + +func (c *Config) awsSecretsManagerTemplateFunc(arn string) map[string]any { + if secret, ok := c.AWSSecretsManager.jsonCache[arn]; ok { + return secret + } + + raw := c.awsSecretsManagerRawTemplateFunc(arn) + + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + panic(err) + } + + if c.AWSSecretsManager.jsonCache == nil { + c.AWSSecretsManager.jsonCache = make(map[string]map[string]any) + } + + c.AWSSecretsManager.jsonCache[arn] = data + return data +} diff --git a/internal/cmd/azurekeyvaulttemplatefuncs.go b/internal/cmd/azurekeyvaulttemplatefuncs.go new file mode 100644 index 00000000000..31f6a52a273 --- /dev/null +++ b/internal/cmd/azurekeyvaulttemplatefuncs.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" +) + +type azureKeyVault struct { + client *azsecrets.Client + cache map[string]string +} + +func (v *azureKeyVault) URL(vaultName string) string { + return fmt.Sprintf("https://%s.vault.azure.net/", vaultName) +} + +type azureKeyVaultConfig struct { + DefaultVault string `json:"defaultVault" mapstructure:"defaultVault" yaml:"defaultVault"` + vaults map[string]*azureKeyVault + cred *azidentity.DefaultAzureCredential +} + +func (a *azureKeyVaultConfig) GetSecret(secretName, vaultName string) string { + var err error + + if a.vaults == nil { + a.vaults = make(map[string]*azureKeyVault) + } + + if _, ok := a.vaults[vaultName]; !ok { + a.vaults[vaultName] = &azureKeyVault{} + } + + if secret, ok := a.vaults[vaultName].cache[secretName]; ok { + return secret + } + + if a.cred == nil { + a.cred, err = azidentity.NewDefaultAzureCredential(nil) + if err != nil { + panic(err) + } + } + + if a.vaults[vaultName].client == nil { + a.vaults[vaultName].client, err = azsecrets.NewClient(a.vaults[vaultName].URL(vaultName), a.cred, nil) + if err != nil { + panic(err) + } + } + + resp, err := a.vaults[vaultName].client.GetSecret(context.Background(), secretName, "", nil) + if err != nil { + panic(err) + } + + if a.vaults[vaultName].cache == nil { + a.vaults[vaultName].cache = make(map[string]string) + } + + a.vaults[vaultName].cache[secretName] = *resp.Value + + return *resp.Value +} + +func (c *Config) azureKeyVaultTemplateFunc(args ...string) string { + var secretName, vaultName string + + switch len(args) { + case 1: + if c.AzureKeyVault.DefaultVault == "" { + panic(errors.New("no value set in azureKeyVault.defaultVault")) + } + secretName, vaultName = args[0], c.AzureKeyVault.DefaultVault + case 2: + secretName, vaultName = args[0], args[1] + default: + panic(fmt.Errorf("expected 1 or 2 arguments, got %d", len(args))) + } + + return c.AzureKeyVault.GetSecret(secretName, vaultName) +} diff --git a/internal/cmd/bitwardensecretstemplatefuncs.go b/internal/cmd/bitwardensecretstemplatefuncs.go new file mode 100644 index 00000000000..8450d8d79f9 --- /dev/null +++ b/internal/cmd/bitwardensecretstemplatefuncs.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +type bitwardenSecretsConfig struct { + Command string `json:"command" mapstructure:"command" yaml:"command"` + outputCache map[string][]byte +} + +func (c *Config) bitwardenSecretsTemplateFunc(secretID string, additionalArgs ...string) any { + args := []string{"secret", "get", secretID} + switch len(additionalArgs) { + case 0: + // Do nothing. + case 1: + args = append(args, "--access-token", additionalArgs[0]) + default: + panic(fmt.Errorf("expected 1 or 2 arguments, got %d", len(additionalArgs)+1)) + } + output, err := c.bitwardenSecretsOutput(args) + if err != nil { + panic(err) + } + var data map[string]any + if err := json.Unmarshal(output, &data); err != nil { + panic(newParseCmdOutputError(c.BitwardenSecrets.Command, args, output, err)) + } + return data +} + +func (c *Config) bitwardenSecretsOutput(args []string) ([]byte, error) { + key := strings.Join(args, "\x00") + if data, ok := c.Bitwarden.outputCache[key]; ok { + return data, nil + } + + name := c.BitwardenSecrets.Command + cmd := exec.Command(name, args...) + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) + if err != nil { + return nil, newCmdOutputError(cmd, output, err) + } + + if c.BitwardenSecrets.outputCache == nil { + c.BitwardenSecrets.outputCache = make(map[string][]byte) + } + c.BitwardenSecrets.outputCache[key] = output + return output, nil +} diff --git a/internal/cmd/bitwardentemplatefuncs.go b/internal/cmd/bitwardentemplatefuncs.go index a2feaac15da..6548f5b2047 100644 --- a/internal/cmd/bitwardentemplatefuncs.go +++ b/internal/cmd/bitwardentemplatefuncs.go @@ -2,26 +2,52 @@ package cmd import ( "encoding/json" - "fmt" + "os" "os/exec" "strings" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) type bitwardenConfig struct { - Command string + Command string `json:"command" mapstructure:"command" yaml:"command"` outputCache map[string][]byte } -func (c *Config) bitwardenFieldsTemplateFunc(args ...string) map[string]interface{} { - output := c.bitwardenOutput(args) +func (c *Config) bitwardenAttachmentTemplateFunc(name, itemID string) string { + output, err := c.bitwardenOutput([]string{"get", "attachment", name, "--itemid", itemID, "--raw"}) + if err != nil { + panic(err) + } + return string(output) +} + +func (c *Config) bitwardenAttachmentByRefTemplateFunc(name string, args ...string) string { + output, err := c.bitwardenOutput(append([]string{"get"}, args...)) + if err != nil { + panic(err) + } + var data struct { + ID string `json:"id"` + } + if err := json.Unmarshal(output, &data); err != nil { + panic(newParseCmdOutputError(c.Bitwarden.Command, args, output, err)) + } + return c.bitwardenAttachmentTemplateFunc(name, data.ID) +} + +func (c *Config) bitwardenFieldsTemplateFunc(args ...string) map[string]any { + output, err := c.bitwardenOutput(append([]string{"get"}, args...)) + if err != nil { + panic(err) + } var data struct { - Fields []map[string]interface{} `json:"fields"` + Fields []map[string]any `json:"fields"` } if err := json.Unmarshal(output, &data); err != nil { - returnTemplateError(fmt.Errorf("%s: %w\n%s", shellQuoteCommand(c.Bitwarden.Command, args), err, output)) - return nil + panic(newParseCmdOutputError(c.Bitwarden.Command, args, output, err)) } - result := make(map[string]interface{}) + result := make(map[string]any) for _, field := range data.Fields { if name, ok := field["name"].(string); ok { result[name] = field @@ -30,40 +56,36 @@ func (c *Config) bitwardenFieldsTemplateFunc(args ...string) map[string]interfac return result } -func (c *Config) bitwardenOutput(args []string) []byte { +func (c *Config) bitwardenTemplateFunc(args ...string) map[string]any { + output, err := c.bitwardenOutput(append([]string{"get"}, args...)) + if err != nil { + panic(err) + } + var data map[string]any + if err := json.Unmarshal(output, &data); err != nil { + panic(newParseCmdOutputError(c.Bitwarden.Command, args, output, err)) + } + return data +} + +func (c *Config) bitwardenOutput(args []string) ([]byte, error) { key := strings.Join(args, "\x00") if data, ok := c.Bitwarden.outputCache[key]; ok { - return data + return data, nil } 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) + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) if err != nil { - returnTemplateError(fmt.Errorf("%s: %w\n%s", shellQuoteCommand(name, args), err, output)) - return nil + return nil, newCmdOutputError(cmd, output, err) } 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: %w\n%s", shellQuoteCommand(c.Bitwarden.Command, args), err, output)) - return nil - } - return data -} - -func (c *Config) bitwardenAttachmentTemplateFunc(name, itemid string) string { - return string(c.bitwardenOutput([]string{"attachment", name, "--itemid", itemid, "--raw"})) + return output, nil } diff --git a/internal/cmd/catcmd.go b/internal/cmd/catcmd.go index f56079651a3..0014943424b 100644 --- a/internal/cmd/catcmd.go +++ b/internal/cmd/catcmd.go @@ -11,21 +11,23 @@ import ( func (c *Config) newCatCmd() *cobra.Command { catCmd := &cobra.Command{ - Use: "cat target...", - Short: "Print the target contents of a file, script, or symlink", - Long: mustLongHelp("cat"), - Example: example("cat"), - Args: cobra.MinimumNArgs(1), - RunE: c.makeRunEWithSourceState(c.runCatCmd), + Use: "cat target...", + Short: "Print the target contents of a file, script, or symlink", + Long: mustLongHelp("cat"), + Example: example("cat"), + ValidArgsFunction: c.targetValidArgs, + Args: cobra.MinimumNArgs(1), + RunE: c.makeRunEWithSourceState(c.runCatCmd), + Annotations: newAnnotations( + requiresSourceDirectory, + ), } return catCmd } func (c *Config) runCatCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { - targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ - mustBeInSourceState: true, - }) + targetRelPaths, err := c.targetRelPaths(sourceState, args, nil) if err != nil { return err } diff --git a/internal/cmd/catcmd_test.go b/internal/cmd/catcmd_test.go new file mode 100644 index 00000000000..d4744236194 --- /dev/null +++ b/internal/cmd/catcmd_test.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "runtime" + "strings" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + + "github.com/twpayne/chezmoi/v2/internal/chezmoitest" +) + +func TestCatCmd(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fails due to Windows paths on GitHub Actions") + } + for _, tc := range []struct { + name string + root any + args []string + expectedStr string + }{ + { + name: "template_delimiters", + root: map[string]any{ + "/home/user/.local/share/chezmoi/dot_template.tmpl": chezmoitest.JoinLines( + `# chezmoi:template:left-delimiter=[[ right-delimiter=]]`, + `[[ "ok" ]]`, + ), + }, + args: []string{ + "/home/user/.template", + }, + expectedStr: chezmoitest.JoinLines( + "ok", + ), + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fileSystem vfs.FS) { + stdout := strings.Builder{} + c := newTestConfig(t, fileSystem, withStdout(&stdout)) + assert.NoError(t, c.execute(append([]string{"cat"}, tc.args...))) + assert.Equal(t, tc.expectedStr, stdout.String()) + }) + }) + } +} diff --git a/internal/cmd/catconfigcmd.go b/internal/cmd/catconfigcmd.go new file mode 100644 index 00000000000..0bd269b7211 --- /dev/null +++ b/internal/cmd/catconfigcmd.go @@ -0,0 +1,28 @@ +package cmd + +import "github.com/spf13/cobra" + +func (c *Config) newCatConfigCmd() *cobra.Command { + catConfigCmd := &cobra.Command{ + Use: "cat-config", + Short: "Print the configuration file", + Long: mustLongHelp("cat-config"), + Example: example("cat-config"), + Args: cobra.NoArgs, + RunE: c.runCatConfigCmd, + Annotations: newAnnotations( + doesNotRequireValidConfig, + requiresConfigDirectory, + ), + } + + return catConfigCmd +} + +func (c *Config) runCatConfigCmd(cmd *cobra.Command, args []string) error { + data, err := c.baseSystem.ReadFile(c.getConfigFileAbsPath()) + if err != nil { + return err + } + return c.writeOutput(data) +} diff --git a/internal/cmd/cdcmd.go b/internal/cmd/cdcmd.go index e88481169ee..89c7d737622 100644 --- a/internal/cmd/cdcmd.go +++ b/internal/cmd/cdcmd.go @@ -1,38 +1,89 @@ package cmd import ( + "fmt" + "os" + "github.com/spf13/cobra" "github.com/twpayne/go-shell" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) type cdCmdConfig struct { - Command string `mapstructure:"command"` - Args []string `mapstructure:"args"` + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` } func (c *Config) newCDCmd() *cobra.Command { cdCmd := &cobra.Command{ - Use: "cd", + Use: "cd [path]", 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", - requiresWorkingTree: "true", - runsCommands: "true", - }, + Args: cobra.MaximumNArgs(1), + Annotations: newAnnotations( + createSourceDirectoryIfNeeded, + doesNotRequireValidConfig, + requiresWorkingTree, + runsCommands, + ), } return cdCmd } func (c *Config) runCDCmd(cmd *cobra.Command, args []string) error { - shellCommand := c.CD.Command - if shellCommand == "" { - shellCommand, _ = shell.CurrentUserShell() + os.Setenv("CHEZMOI_SUBSHELL", "1") + + cdCommand, cdArgs, err := c.cdCommand() + if err != nil { + return err + } + var dir chezmoi.AbsPath + if len(args) == 0 { + dir = c.WorkingTreeAbsPath + } else { + switch argAbsPath, err := chezmoi.NewAbsPathFromExtPath(args[0], c.homeDirAbsPath); { + case err != nil: + return err + case argAbsPath == c.DestDirAbsPath: + dir, err = c.getSourceDirAbsPath(nil) + if err != nil { + return err + } + default: + sourceState, err := c.getSourceState(cmd.Context(), cmd) + if err != nil { + return err + } + sourceAbsPaths, err := c.sourceAbsPaths(sourceState, args) + if err != nil { + return err + } + dir = sourceAbsPaths[0] + } + } + + switch fileInfo, err := c.baseSystem.Stat(dir); { + case err != nil: + return err + case !fileInfo.IsDir(): + return fmt.Errorf("%s: not a directory", dir) } - return c.run(c.WorkingTreeAbsPath, shellCommand, c.CD.Args) + + return c.run(dir, cdCommand, cdArgs) +} + +func (c *Config) cdCommand() (string, []string, error) { + cdCommand := c.CD.Command + cdArgs := c.CD.Args + + if cdCommand != "" { + return cdCommand, cdArgs, nil + } + + cdCommand, _ = shell.CurrentUserShell() + return parseCommand(cdCommand, cdArgs) } diff --git a/internal/cmd/chattrcmd.go b/internal/cmd/chattrcmd.go index 45b6055e546..689b3f1dc22 100644 --- a/internal/cmd/chattrcmd.go +++ b/internal/cmd/chattrcmd.go @@ -10,6 +10,10 @@ import ( "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) +type chattrCmdConfig struct { + recursive bool +} + type boolModifier int const ( @@ -46,6 +50,8 @@ const ( sourceFileTypeModifierClearCreate sourceFileTypeModifierSetModify sourceFileTypeModifierClearModify + sourceFileTypeModifierSetRemove + sourceFileTypeModifierClearRemove sourceFileTypeModifierSetScript sourceFileTypeModifierClearScript sourceFileTypeModifierSetSymlink @@ -59,51 +65,83 @@ type modifier struct { encrypted boolModifier exact boolModifier executable boolModifier + external boolModifier order orderModifier private boolModifier readOnly boolModifier + remove boolModifier template boolModifier } func (c *Config) newChattrCmd() *cobra.Command { - attrs := []string{ - "after", "a", - "before", "b", - "create", - "empty", "e", - "encrypted", - "exact", - "executable", "x", - "modify", - "once", "o", - "onchange", - "private", "p", - "readonly", "r", - "script", - "symlink", - "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", - }, + 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), + ValidArgsFunction: c.chattrCmdValidArgs, + RunE: c.makeRunEWithSourceState(c.runChattrCmd), + Annotations: newAnnotations( + modifiesSourceDirectory, + requiresSourceDirectory, + ), } + chattrCmd.Flags().BoolVarP(&c.chattr.recursive, "recursive", "r", c.chattr.recursive, "Recurse into subdirectories") + return chattrCmd } +// chattrCmdValidArgs returns the completions for the chattr command. +func (c *Config) chattrCmdValidArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + switch len(args) { + case 0: + prefixes := []string{"", "-", "+", "no"} + attributes := []string{ + "after", + "before", + "create", + "empty", + "encrypted", + "exact", + "executable", + "external", + "modify", + "once", + "onchange", + "private", + "readonly", + "remove", + "script", + "symlink", + "template", + } + validModifiers := make([]string, 0, len(prefixes)*len(attributes)) + for _, prefix := range prefixes { + for _, attribute := range attributes { + modifier := prefix + attribute + validModifiers = append(validModifiers, modifier) + } + } + + modifiers := strings.Split(toComplete, ",") + modifierToComplete := modifiers[len(modifiers)-1] + completionPrefix := toComplete[:len(toComplete)-len(modifierToComplete)] + var completions []string + for _, modifier := range validModifiers { + if strings.HasPrefix(modifier, modifierToComplete) { + completion := completionPrefix + modifier + completions = append(completions, completion) + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp + default: + return c.targetValidArgs(cmd, args, toComplete) + } +} + func (c *Config) runChattrCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { // LATER should the core functionality of chattr move to chezmoi.SourceState? @@ -112,8 +150,8 @@ func (c *Config) runChattrCmd(cmd *cobra.Command, args []string, sourceState *ch return err } - targetRelPaths, err := c.targetRelPaths(sourceState, args[1:], targetRelPathsOptions{ - mustBeInSourceState: true, + targetRelPaths, err := c.targetRelPaths(sourceState, args[1:], &targetRelPathsOptions{ + recursive: c.chattr.recursive, }) if err != nil { return err @@ -121,7 +159,7 @@ func (c *Config) runChattrCmd(cmd *cobra.Command, args []string, sourceState *ch // Sort targets in reverse so we update children before their parent // directories. - sort.Sort(targetRelPaths) + sort.Sort(sort.Reverse(targetRelPaths)) encryptedSuffix := sourceState.Encryption().EncryptedSuffix() for _, targetRelPath := range targetRelPaths { @@ -141,12 +179,41 @@ func (c *Config) runChattrCmd(cmd *cobra.Command, args []string, sourceState *ch } } case *chezmoi.SourceStateFile: - // FIXME encrypted attribute changes - // FIXME when changing encrypted attribute add new file before removing old one - relPath := m.modifyFileAttr(sourceStateEntry.Attr).SourceName(encryptedSuffix) - if newBaseNameRelPath := chezmoi.NewRelPath(relPath); newBaseNameRelPath != fileRelPath { - oldSourceAbsPath := c.SourceDirAbsPath.Join(parentRelPath, fileRelPath) - newSourceAbsPath := c.SourceDirAbsPath.Join(parentRelPath, newBaseNameRelPath) + newAttr := m.modifyFileAttr(sourceStateEntry.Attr) + newBaseNameRelPath := chezmoi.NewRelPath(newAttr.SourceName(encryptedSuffix)) + oldSourceAbsPath := c.SourceDirAbsPath.Join(parentRelPath, fileRelPath) + newSourceAbsPath := c.SourceDirAbsPath.Join(parentRelPath, newBaseNameRelPath) + switch encryptedBefore, encryptedAfter := sourceStateEntry.Attr.Encrypted, newAttr.Encrypted; { + case encryptedBefore && !encryptedAfter: + // Write the plaintext and then remove the ciphertext. + plaintext, err := sourceStateEntry.Contents() + if err != nil { + return err + } + if err := c.sourceSystem.WriteFile(newSourceAbsPath, plaintext, 0o666&^c.Umask); err != nil { + return err + } + if err := c.sourceSystem.Remove(oldSourceAbsPath); err != nil { + return err + } + case !encryptedBefore && encryptedAfter: + // Write the ciphertext and then remove the plaintext. + plaintext, err := sourceStateEntry.Contents() + if err != nil { + return err + } + ciphertext, err := sourceState.Encryption().Encrypt(plaintext) + if err != nil { + return err + } + if err := c.sourceSystem.WriteFile(newSourceAbsPath, ciphertext, 0o666&^c.Umask); err != nil { + return err + } + if err := c.sourceSystem.Remove(oldSourceAbsPath); err != nil { + return err + } + case newBaseNameRelPath != fileRelPath: + // Contents have not changed so a rename is sufficient. if err := c.sourceSystem.Rename(oldSourceAbsPath, newSourceAbsPath); err != nil { return err } @@ -231,6 +298,13 @@ func (m sourceFileTypeModifier) modify(sourceFileType chezmoi.SourceFileTargetTy return chezmoi.SourceFileTypeFile } return sourceFileType + case sourceFileTypeModifierSetRemove: + return chezmoi.SourceFileTypeRemove + case sourceFileTypeModifierClearRemove: + if sourceFileType == chezmoi.SourceFileTypeRemove { + return chezmoi.SourceFileTypeFile + } + return sourceFileType case sourceFileTypeModifierSetModify: return chezmoi.SourceFileTypeModify case sourceFileTypeModifierClearModify: @@ -257,7 +331,7 @@ func (m sourceFileTypeModifier) modify(sourceFileType chezmoi.SourceFileTargetTy } } -// parseModifier parses the attrMmodifier from s. +// parseModifier parses the modifier from s. func parseModifier(s string) (*modifier, error) { m := &modifier{} for _, modifierStr := range strings.Split(s, ",") { @@ -315,6 +389,8 @@ func parseModifier(s string) (*modifier, error) { m.exact = bm case "executable", "x": m.executable = bm + case "external": + m.external = bm case "modify": switch bm { case boolModifierClear: @@ -340,6 +416,15 @@ func parseModifier(s string) (*modifier, error) { m.private = bm case "readonly", "r": m.readOnly = bm + case "remove": + switch bm { + case boolModifierClear: + m.remove = bm + m.sourceFileType = sourceFileTypeModifierClearRemove + case boolModifierSet: + m.remove = bm + m.sourceFileType = sourceFileTypeModifierSetRemove + } case "script": switch bm { case boolModifierClear: @@ -368,8 +453,10 @@ func (m *modifier) modifyDirAttr(dirAttr chezmoi.DirAttr) chezmoi.DirAttr { return chezmoi.DirAttr{ TargetName: dirAttr.TargetName, Exact: m.exact.modify(dirAttr.Exact), + External: m.external.modify(dirAttr.External), Private: m.private.modify(dirAttr.Private), ReadOnly: m.readOnly.modify(dirAttr.ReadOnly), + Remove: m.remove.modify(dirAttr.Remove), } } @@ -400,6 +487,7 @@ func (m *modifier) modifyFileAttr(fileAttr chezmoi.FileAttr) chezmoi.FileAttr { return chezmoi.FileAttr{ TargetName: fileAttr.TargetName, Type: chezmoi.SourceFileTypeCreate, + Empty: m.encrypted.modify(fileAttr.Empty), Encrypted: m.encrypted.modify(fileAttr.Encrypted), Executable: m.executable.modify(fileAttr.Executable), Private: m.private.modify(fileAttr.Private), @@ -419,6 +507,11 @@ func (m *modifier) modifyFileAttr(fileAttr chezmoi.FileAttr) chezmoi.FileAttr { Type: chezmoi.SourceFileTypeSymlink, Template: m.template.modify(fileAttr.Template), } + case chezmoi.SourceFileTypeRemove: + return chezmoi.FileAttr{ + TargetName: fileAttr.TargetName, + Type: chezmoi.SourceFileTypeRemove, + } default: panic(fmt.Sprintf("%d: unknown source file type", fileAttr.Type)) } diff --git a/internal/cmd/chattrcmd_test.go b/internal/cmd/chattrcmd_test.go index ef1684d3972..f0eba2babf4 100644 --- a/internal/cmd/chattrcmd_test.go +++ b/internal/cmd/chattrcmd_test.go @@ -1,12 +1,61 @@ package cmd import ( + "fmt" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/alecthomas/assert/v2" + "github.com/spf13/cobra" ) +func TestChattrCmdValidArgs(t *testing.T) { + for _, tc := range []struct { + args []string + toComplete string + expectedCompletions []string + expectedShellCompDirective cobra.ShellCompDirective + }{ + { + toComplete: "a", + expectedCompletions: []string{"after"}, + expectedShellCompDirective: cobra.ShellCompDirectiveNoFileComp, + }, + { + toComplete: "e", + expectedCompletions: []string{"empty", "encrypted", "exact", "executable", "external"}, + expectedShellCompDirective: cobra.ShellCompDirectiveNoFileComp, + }, + { + toComplete: "-c", + expectedCompletions: []string{"-create"}, + expectedShellCompDirective: cobra.ShellCompDirectiveNoFileComp, + }, + { + toComplete: "+o", + expectedCompletions: []string{"+once", "+onchange"}, + expectedShellCompDirective: cobra.ShellCompDirectiveNoFileComp, + }, + { + toComplete: "nop", + expectedCompletions: []string{"noprivate"}, + expectedShellCompDirective: cobra.ShellCompDirectiveNoFileComp, + }, + { + toComplete: "empty,s", + expectedCompletions: []string{"empty,script", "empty,symlink"}, + expectedShellCompDirective: cobra.ShellCompDirectiveNoFileComp, + }, + } { + name := fmt.Sprintf("chattrValidArgs(_, %+v, %q)", tc.args, tc.toComplete) + t.Run(name, func(t *testing.T) { + c := &Config{} + actualCompletions, actualShellCompDirective := c.chattrCmdValidArgs(&cobra.Command{}, tc.args, tc.toComplete) + assert.Equal(t, tc.expectedCompletions, actualCompletions) + assert.Equal(t, tc.expectedShellCompDirective, actualShellCompDirective) + }) + } +} + func TestParseAttrModifier(t *testing.T) { for _, tc := range []struct { s string @@ -177,9 +226,9 @@ func TestParseAttrModifier(t *testing.T) { t.Run(tc.s, func(t *testing.T) { actual, err := parseModifier(tc.s) if tc.expectedErr { - require.Error(t, err) + assert.Error(t, err) } else { - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, tc.expected, actual) } }) diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 2906deeb926..7026c8385f8 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -2,66 +2,37 @@ package cmd import ( - "bufio" - "bytes" "errors" "fmt" - "io" + "log/slog" "os" + "os/exec" "regexp" + "runtime/debug" "strconv" "strings" "github.com/charmbracelet/glamour" - "github.com/rs/zerolog" "github.com/spf13/cobra" "go.etcd.io/bbolt" - "go.uber.org/multierr" - "github.com/twpayne/chezmoi/v2/docs" + "github.com/twpayne/chezmoi/v2/assets/chezmoi.io/docs/reference/commands" + "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoierrors" + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" ) -// 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" - requiresWorkingTree = "chezmoi_requires_working_tree" - runsCommands = "chezmoi_runs_commands" -) - -// Persistent state modes. -const ( - persistentStateModeEmpty = "empty" - persistentStateModeReadOnly = "read-only" - persistentStateModeReadMockWrite = "read-mock-write" - persistentStateModeReadWrite = "read-write" -) +const readSourceStateHookName = "read-source-state" var ( noArgs = []string(nil) - commandsRx = regexp.MustCompile(`^## Commands`) - commandRx = regexp.MustCompile("^### `(\\S+)`") - exampleRx = regexp.MustCompile("^#### `.+` examples") - optionRx = regexp.MustCompile("^#### `(-\\w|--\\w+)`") - endOfCommandsRx = regexp.MustCompile("^## ") - horizontalRuleRx = regexp.MustCompile(`^---`) - trailingSpaceRx = regexp.MustCompile(` +\n`) + deDuplicateErrorRx = regexp.MustCompile(`:\s+`) + trailingSpaceRx = regexp.MustCompile(` +\n`) - helps map[string]*help + helps = make(map[string]*help) ) -// An ExitCodeError indicates the the main program should exit with the given -// code. -type ExitCodeError int - -func (e ExitCodeError) Error() string { return "" } - // A VersionInfo contains a version. type VersionInfo struct { Version string @@ -71,54 +42,91 @@ type VersionInfo struct { } type help struct { - long string - example string + longHelp string + example string } func init() { - reference, err := docs.FS.ReadFile("REFERENCE.md") + dirEntries, err := commands.FS.ReadDir(".") + if err != nil { + panic(err) + } + + longHelpStyleConfig := glamour.ASCIIStyleConfig + longHelpStyleConfig.Code.StylePrimitive.BlockPrefix = "" + longHelpStyleConfig.Code.StylePrimitive.BlockSuffix = "" + longHelpStyleConfig.Emph.BlockPrefix = "" + longHelpStyleConfig.Emph.BlockSuffix = "" + longHelpStyleConfig.H2.Prefix = "" + longHelpTermRenderer, err := glamour.NewTermRenderer( + glamour.WithStyles(longHelpStyleConfig), + glamour.WithWordWrap(80), + ) if err != nil { panic(err) } - helps, err = extractHelps(bytes.NewReader(reference)) + + exampleStyleConfig := glamour.ASCIIStyleConfig + exampleStyleConfig.Code.StylePrimitive.BlockPrefix = "" + exampleStyleConfig.Code.StylePrimitive.BlockSuffix = "" + exampleStyleConfig.Document.Margin = nil + exampleTermRenderer, err := glamour.NewTermRenderer( + glamour.WithStyles(exampleStyleConfig), + glamour.WithWordWrap(80), + ) if err != nil { panic(err) } + + for _, dirEntry := range dirEntries { + command := strings.TrimSuffix(dirEntry.Name(), ".md") + data, err := commands.FS.ReadFile(dirEntry.Name()) + if err != nil { + panic(err) + } + help, err := extractHelp(command, data, longHelpTermRenderer, exampleTermRenderer) + if err != nil { + panic(err) + } + helps[command] = help + } } -// MarshalZerologObject implements -// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. -func (v VersionInfo) MarshalZerologObject(e *zerolog.Event) { - e.Str("version", v.Version) - e.Str("commit", v.Commit) - e.Str("date", v.Date) - e.Str("builtBy", v.BuiltBy) +func (v VersionInfo) LogValue() slog.Value { + return slog.GroupValue( + slog.String("version", v.Version), + slog.String("commit", v.Commit), + slog.String("date", v.Date), + slog.String("builtBy", v.BuiltBy), + ) } // 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) + if errExitCode := chezmoi.ExitCodeError(0); errors.As(err, &errExitCode) { + return int(errExitCode) } - errExitCode := ExitCodeError(1) - _ = errors.As(err, &errExitCode) - return int(errExitCode) + fmt.Fprintf(os.Stderr, "chezmoi: %s\n", deDuplicateError(err)) + return 1 } return 0 } -// boolAnnotation returns whether cmd is annotated with key. -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) +// deDuplicateError returns err's human-readable string with duplicate components +// removed. +func deDuplicateError(err error) string { + components := deDuplicateErrorRx.Split(err.Error(), -1) + seenComponents := chezmoiset.NewWithCapacity[string](len(components)) + uniqueComponents := make([]string, 0, len(components)) + for _, component := range components { + if seenComponents.Contains(component) { + continue + } + uniqueComponents = append(uniqueComponents, component) + seenComponents.Add(component) } - return boolValue + return strings.Join(uniqueComponents, ": ") } // example returns command's example. @@ -130,136 +138,83 @@ func example(command string) string { return help.example } -// extractHelps returns the helps parse from r. -func extractHelps(r io.Reader) (map[string]*help, error) { - longStyleConfig := glamour.ASCIIStyleConfig - longStyleConfig.Code.StylePrimitive.BlockPrefix = "" - longStyleConfig.Code.StylePrimitive.BlockSuffix = "" - longStyleConfig.Emph.BlockPrefix = "" - longStyleConfig.Emph.BlockSuffix = "" - longStyleConfig.H4.Prefix = "" - longTermRenderer, err := glamour.NewTermRenderer( - glamour.WithStyles(longStyleConfig), - glamour.WithWordWrap(80), - ) - if err != nil { - return nil, err - } - - examplesStyleConfig := glamour.ASCIIStyleConfig - examplesStyleConfig.Document.Margin = nil - examplesTermRenderer, err := glamour.NewTermRenderer( - glamour.WithStyles(examplesStyleConfig), - glamour.WithWordWrap(80), - ) - if err != nil { - return nil, err - } - +// extractHelp returns the helps parse from r. +func extractHelp(command string, data []byte, longHelpTermRenderer, exampleTermRenderer *glamour.TermRenderer) (*help, error) { type stateType int const ( - stateFindCommands stateType = iota - stateFindFirstCommand - stateInCommand - stateFindExample + stateReadTitle stateType = iota + stateInLongHelp + stateInOptions stateInExample + stateInAdmonition ) - var ( - state = stateFindCommands - builder = &strings.Builder{} - h *help - ) - - saveAndReset := func() error { - var termRenderer *glamour.TermRenderer + state := stateReadTitle + var longHelpLines []string + var exampleLines []string + for _, line := range strings.Split(string(data), "\n") { switch state { - case stateInCommand, stateFindExample: - termRenderer = longTermRenderer - case stateInExample: - termRenderer = examplesTermRenderer - default: - panic(fmt.Sprintf("%d: invalid state", state)) - } - s, err := termRenderer.Render(builder.String()) - if err != nil { - return err - } - s = trailingSpaceRx.ReplaceAllString(s, "\n") - s = strings.Trim(s, "\n") - switch state { - case stateInCommand, stateFindExample: - h.long = "Description:\n" + s - case stateInExample: - h.example = s - default: - panic(fmt.Sprintf("%d: invalid state", state)) - } - builder.Reset() - return nil - } - - helps := make(map[string]*help) - s := bufio.NewScanner(r) -FOR: - for s.Scan() { - switch state { - case stateFindCommands: - if commandsRx.MatchString(s.Text()) { - state = stateFindFirstCommand + case stateReadTitle: + titleRx, err := regexp.Compile("# `" + command + "`") + if err != nil { + return nil, err } - case stateFindFirstCommand: - if m := commandRx.FindStringSubmatch(s.Text()); m != nil { - h = &help{} - helps[m[1]] = h - state = stateInCommand + if titleRx.MatchString(line) { + state = stateInLongHelp } - case stateInCommand, stateFindExample, stateInExample: - switch m := commandRx.FindStringSubmatch(s.Text()); { - case m != nil: - if err := saveAndReset(); err != nil { - return nil, err - } - h = &help{} - helps[m[1]] = h - state = stateInCommand - case optionRx.MatchString(s.Text()): - state = stateFindExample - case exampleRx.MatchString(s.Text()): - if err := saveAndReset(); err != nil { - return nil, err - } + case stateInLongHelp: + switch { + case strings.HasPrefix(line, "## "): + state = stateInOptions + case line == "!!! example": + state = stateInExample + case strings.HasPrefix(line, "!!!"): + state = stateInAdmonition + default: + longHelpLines = append(longHelpLines, line) + } + case stateInOptions: + if line == "!!! example" { + state = stateInExample + } + case stateInExample: + exampleLines = append(exampleLines, strings.TrimPrefix(line, " ")) + case stateInAdmonition: + if line == "!!! example" { state = stateInExample - case endOfCommandsRx.MatchString(s.Text()): - if err := saveAndReset(); err != nil { - return nil, err - } - break FOR - case horizontalRuleRx.MatchString(s.Text()): - if err := saveAndReset(); err != nil { - return nil, err - } - state = stateFindFirstCommand - case state != stateFindExample: - if _, err := builder.WriteString(s.Text()); err != nil { - return nil, err - } - if err := builder.WriteByte('\n'); err != nil { - return nil, err - } } } } - if err := s.Err(); err != nil { + + longHelp, err := renderLines(longHelpLines, longHelpTermRenderer) + if err != nil { + return nil, err + } + example, err := renderLines(exampleLines, exampleTermRenderer) + if err != nil { return nil, err } - return helps, nil + return &help{ + longHelp: "Description:\n" + longHelp, + example: example, + }, nil } -// markPersistentFlagsRequired marks all of flags as required for cmd. -func markPersistentFlagsRequired(cmd *cobra.Command, flags ...string) { +// renderLines renders lines, trimming extraneous whitespace. +func renderLines(lines []string, termRenderer *glamour.TermRenderer) (string, error) { + renderedLines, err := termRenderer.Render(strings.Join(lines, "\n")) + if err != nil { + return "", err + } + renderedLines = trailingSpaceRx.ReplaceAllString(renderedLines, "\n") + renderedLines = strings.Trim(renderedLines, "\n") + return renderedLines, nil +} + +// markFlagsRequired marks all of flags as required for cmd. +func markFlagsRequired(cmd *cobra.Command, flags ...string) { for _, flag := range flags { - if err := cmd.MarkPersistentFlagRequired(flag); err != nil { + if err := cmd.MarkFlagRequired(flag); err != nil { panic(err) } } @@ -270,28 +225,82 @@ func markPersistentFlagsRequired(cmd *cobra.Command, flags ...string) { func mustLongHelp(command string) string { help, ok := helps[command] if !ok { - panic(fmt.Sprintf("missing long help for command %s", command)) + panic(command + ": missing long help") } - return help.long + return help.longHelp } // runMain runs chezmoi's main function. func runMain(versionInfo VersionInfo, args []string) (err error) { + if versionInfo.Commit == "" || versionInfo.Date == "" { + if buildInfo, ok := debug.ReadBuildInfo(); ok { + var vcs, vcsRevision, vcsTime, vcsModified string + for _, setting := range buildInfo.Settings { + switch setting.Key { + case "vcs": + vcs = setting.Value + case "vcs.revision": + vcsRevision = setting.Value + case "vcs.time": + vcsTime = setting.Value + case "vcs.modified": + vcsModified = setting.Value + } + } + if versionInfo.Commit == "" && vcs == "git" { + versionInfo.Commit = vcsRevision + if modified, err := strconv.ParseBool(vcsModified); err == nil && modified { + versionInfo.Commit += "-dirty" + } + } + if versionInfo.Date == "" { + versionInfo.Date = vcsTime + } + } + } + var config *Config if config, err = newConfig( withVersionInfo(versionInfo), ); err != nil { return err } - defer func() { - err = multierr.Append(err, config.close()) - }() - err = config.execute(args) - if errors.Is(err, bbolt.ErrTimeout) { + defer chezmoierrors.CombineFunc(&err, config.Close) + + switch err = config.execute(args); { + case errors.Is(err, bbolt.ErrTimeout): // Translate bbolt timeout errors into a friendlier message. As the // persistent state is opened lazily, this error could occur at any // time, so it's easiest to intercept it here. - err = errors.New("timeout obtaining persistent state lock, is another instance of chezmoi running?") + return errors.New("timeout obtaining persistent state lock, is another instance of chezmoi running?") + case err != nil && strings.Contains(err.Error(), "unknown command") && len(args) > 0: + // If the command is unknown then look for a plugin. + if name, lookPathErr := exec.LookPath("chezmoi-" + args[0]); lookPathErr == nil { + // The following is a bit of a hack, as cobra does not have a way to + // call a function if a command is not found. We need to run the + // pre- and post- run commands to set up the environment, so we + // create a fake cobra.Command that corresponds to the name of the + // plugin. + cmd := &cobra.Command{ + Use: args[0], + Annotations: newAnnotations( + doesNotRequireValidConfig, + persistentStateModeEmpty, + runsCommands, + ), + } + if err := config.persistentPreRunRootE(cmd, args[1:]); err != nil { + return err + } + pluginCmd := exec.Command(name, args[1:]...) + pluginCmd.Stdin = os.Stdin + pluginCmd.Stdout = os.Stdout + pluginCmd.Stderr = os.Stderr + err = config.run("", name, args[1:]) + if persistentPostRunRootEErr := config.persistentPostRunRootE(cmd, args[1:]); persistentPostRunRootEErr != nil { + err = chezmoierrors.Combine(err, persistentPostRunRootEErr) + } + } } return } diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index b317605f335..e8e75cc134c 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1,9 +1,11 @@ package cmd import ( + "errors" + "strconv" "testing" - "github.com/stretchr/testify/assert" + "github.com/alecthomas/assert/v2" "github.com/twpayne/chezmoi/v2/internal/chezmoi" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" @@ -15,6 +17,51 @@ func init() { chezmoi.Umask = chezmoitest.Umask } +func TestDeDuplicateError(t *testing.T) { + for i, tc := range []struct { + errStr string + expected string + }{ + { + errStr: "", + expected: "", + }, + { + errStr: "a", + expected: "a", + }, + { + errStr: "a: a", + expected: "a", + }, + { + errStr: "a: b", + expected: "a: b", + }, + { + errStr: "a: a: b", //nolint:dupword + expected: "a: b", + }, + { + errStr: "a: b: b", + expected: "a: b", + }, + { + errStr: "a: b: c: b: a: d", + expected: "a: b: c: d", + }, + { + errStr: "a: b: a: b: c", + expected: "a: b: c", + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + actual := deDuplicateError(errors.New(tc.errStr)) + assert.Equal(t, tc.expected, actual) + }) + } +} + func TestMustGetLongHelpPanics(t *testing.T) { assert.Panics(t, func() { mustLongHelp("non-existent-command") diff --git a/internal/cmd/completioncmd.go b/internal/cmd/completioncmd.go index bf54ced21d7..aff87ab830c 100644 --- a/internal/cmd/completioncmd.go +++ b/internal/cmd/completioncmd.go @@ -7,6 +7,10 @@ import ( "github.com/spf13/cobra" ) +type completionCmdConfig struct { + Custom bool `json:"custom" mapstructure:"custom" yaml:"custom"` +} + func (c *Config) newCompletionCmd() *cobra.Command { completionCmd := &cobra.Command{ Use: "completion shell", @@ -16,38 +20,46 @@ func (c *Config) newCompletionCmd() *cobra.Command { Long: mustLongHelp("completion"), Example: example("completion"), RunE: c.runCompletionCmd, - Annotations: map[string]string{ - doesNotRequireValidConfig: "true", - }, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), } return completionCmd } func (c *Config) runCompletionCmd(cmd *cobra.Command, args []string) error { + completion, err := completion(cmd, args[0]) + if err != nil { + return err + } + return c.writeOutputString(completion) +} + +func completion(cmd *cobra.Command, shell string) (string, error) { builder := strings.Builder{} builder.Grow(16384) - switch args[0] { + switch shell { case "bash": includeDesc := true if err := cmd.Root().GenBashCompletionV2(&builder, includeDesc); err != nil { - return err + return "", err } case "fish": includeDesc := true if err := cmd.Root().GenFishCompletion(&builder, includeDesc); err != nil { - return err + return "", err } case "powershell": if err := cmd.Root().GenPowerShellCompletion(&builder); err != nil { - return err + return "", err } case "zsh": if err := cmd.Root().GenZshCompletion(&builder); err != nil { - return err + return "", err } default: - return fmt.Errorf("%s: unsupported shell", args[0]) + return "", fmt.Errorf("%s: unsupported shell", shell) } - return c.writeOutputString(builder.String()) + return builder.String(), nil } diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 680352f5499..b1a512a9493 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -9,6 +9,8 @@ import ( "fmt" "io" "io/fs" + "log/slog" + "maps" "net/http" "os" "os/exec" @@ -18,6 +20,7 @@ import ( "regexp" "runtime" "runtime/pprof" + "slices" "sort" "strconv" "strings" @@ -27,28 +30,35 @@ import ( "github.com/Masterminds/sprig/v3" "github.com/coreos/go-semver/semver" - gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/format/diff" - "github.com/google/gops/agent" "github.com/gregjones/httpcache" "github.com/gregjones/httpcache/diskcache" "github.com/mitchellh/mapstructure" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/spf13/afero" + "github.com/muesli/termenv" "github.com/spf13/cobra" - "github.com/spf13/viper" + "github.com/spf13/pflag" "github.com/twpayne/go-shell" - "github.com/twpayne/go-vfs/v4" + "github.com/twpayne/go-vfs/v5" "github.com/twpayne/go-xdg/v6" - "go.uber.org/multierr" + "github.com/zricethezav/gitleaks/v8/detect" "golang.org/x/term" + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/syntax" "github.com/twpayne/chezmoi/v2/assets/templates" "github.com/twpayne/chezmoi/v2/internal/chezmoi" - "github.com/twpayne/chezmoi/v2/internal/git" + "github.com/twpayne/chezmoi/v2/internal/chezmoierrors" + "github.com/twpayne/chezmoi/v2/internal/chezmoigit" + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" ) +// defaultSentinel is a string value used to indicate that the default value +// should be used. It is a string unlikely to be an actual value set by the +// user. +const defaultSentinel = "\x00" + const ( logComponentKey = "component" logComponentValueEncryption = "encryption" @@ -57,133 +67,228 @@ const ( logComponentValueSystem = "system" ) -type purgeOptions struct { - binary bool +type doPurgeOptions struct { + binary bool + cache bool + config bool + persistentState bool + sourceDir bool + workingTree bool +} + +type commandConfig struct { + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` +} + +type hookConfig struct { + Pre commandConfig `json:"pre" mapstructure:"pre" yaml:"pre"` + Post commandConfig `json:"post" mapstructure:"post" yaml:"post"` } type templateConfig struct { - Options []string `mapstructure:"options"` + Options []string `json:"options" mapstructure:"options" yaml:"options"` +} + +type warningsConfig struct { + ConfigFileTemplateHasChanged bool `json:"configFileTemplateHasChanged" mapstructure:"configFileTemplateHasChanged" yaml:"configFileTemplateHasChanged"` +} + +// ConfigFile contains all data settable in the config file. +type ConfigFile struct { + // Global configuration. + CacheDirAbsPath chezmoi.AbsPath `json:"cacheDir" mapstructure:"cacheDir" yaml:"cacheDir"` + Color autoBool `json:"color" mapstructure:"color" yaml:"color"` + Data map[string]any `json:"data" mapstructure:"data" yaml:"data"` + Env map[string]string `json:"env" mapstructure:"env" yaml:"env"` + Format writeDataFormat `json:"format" mapstructure:"format" yaml:"format"` + DestDirAbsPath chezmoi.AbsPath `json:"destDir" mapstructure:"destDir" yaml:"destDir"` + GitHub gitHubConfig `json:"gitHub" mapstructure:"gitHub" yaml:"gitHub"` + Hooks map[string]hookConfig `json:"hooks" mapstructure:"hooks" yaml:"hooks"` + Interpreters map[string]chezmoi.Interpreter `json:"interpreters" mapstructure:"interpreters" yaml:"interpreters"` + Mode chezmoi.Mode `json:"mode" mapstructure:"mode" yaml:"mode"` + Pager string `json:"pager" mapstructure:"pager" yaml:"pager"` + PersistentStateAbsPath chezmoi.AbsPath `json:"persistentState" mapstructure:"persistentState" yaml:"persistentState"` + PINEntry pinEntryConfig `json:"pinentry" mapstructure:"pinentry" yaml:"pinentry"` + Progress autoBool `json:"progress" mapstructure:"progress" yaml:"progress"` + Safe bool `json:"safe" mapstructure:"safe" yaml:"safe"` + ScriptEnv map[string]string `json:"scriptEnv" mapstructure:"scriptEnv" yaml:"scriptEnv"` + ScriptTempDir chezmoi.AbsPath `json:"scriptTempDir" mapstructure:"scriptTempDir" yaml:"scriptTempDir"` + SourceDirAbsPath chezmoi.AbsPath `json:"sourceDir" mapstructure:"sourceDir" yaml:"sourceDir"` + TempDir chezmoi.AbsPath `json:"tempDir" mapstructure:"tempDir" yaml:"tempDir"` + Template templateConfig `json:"template" mapstructure:"template" yaml:"template"` + TextConv textConv `json:"textConv" mapstructure:"textConv" yaml:"textConv"` + Umask fs.FileMode `json:"umask" mapstructure:"umask" yaml:"umask"` + UseBuiltinAge autoBool `json:"useBuiltinAge" mapstructure:"useBuiltinAge" yaml:"useBuiltinAge"` + UseBuiltinGit autoBool `json:"useBuiltinGit" mapstructure:"useBuiltinGit" yaml:"useBuiltinGit"` + Verbose bool `json:"verbose" mapstructure:"verbose" yaml:"verbose"` + Warnings warningsConfig `json:"warnings" mapstructure:"warnings" yaml:"warnings"` + WorkingTreeAbsPath chezmoi.AbsPath `json:"workingTree" mapstructure:"workingTree" yaml:"workingTree"` + + // Password manager configurations. + AWSSecretsManager awsSecretsManagerConfig `json:"awsSecretsManager" mapstructure:"awsSecretsManager" yaml:"awsSecretsManager"` + AzureKeyVault azureKeyVaultConfig `json:"azureKeyVault" mapstructure:"azureKeyVault" yaml:"azureKeyVault"` + Bitwarden bitwardenConfig `json:"bitwarden" mapstructure:"bitwarden" yaml:"bitwarden"` + BitwardenSecrets bitwardenSecretsConfig `json:"bitwardenSecrets" mapstructure:"bitwardenSecrets" yaml:"bitwardenSecrets"` + Dashlane dashlaneConfig `json:"dashlane" mapstructure:"dashlane" yaml:"dashlane"` + Doppler dopplerConfig `json:"doppler" mapstructure:"doppler" yaml:"doppler"` + Ejson ejsonConfig `json:"ejson" mapstructure:"ejson" yaml:"ejson"` + Gopass gopassConfig `json:"gopass" mapstructure:"gopass" yaml:"gopass"` + HCPVaultSecrets hcpVaultSecretConfig `json:"hcpVaultSecrets" mapstructure:"hcpVaultSecrets" yaml:"hcpVaultSecrets"` + Keepassxc keepassxcConfig `json:"keepassxc" mapstructure:"keepassxc" yaml:"keepassxc"` + Keeper keeperConfig `json:"keeper" mapstructure:"keeper" yaml:"keeper"` + Lastpass lastpassConfig `json:"lastpass" mapstructure:"lastpass" yaml:"lastpass"` + Onepassword onepasswordConfig `json:"onepassword" mapstructure:"onepassword" yaml:"onepassword"` + OnepasswordSDK onepasswordSDKConfig `json:"onepasswordSDK" mapstructure:"onepasswordSDK" yaml:"onepasswordSDK"` //nolint:tagliatelle + Pass passConfig `json:"pass" mapstructure:"pass" yaml:"pass"` + Passhole passholeConfig `json:"passhole" mapstructure:"passhole" yaml:"passhole"` + RBW rbwConfig `json:"rbw" mapstructure:"rbw" yaml:"rbw"` + Secret secretConfig `json:"secret" mapstructure:"secret" yaml:"secret"` + Vault vaultConfig `json:"vault" mapstructure:"vault" yaml:"vault"` + + // Encryption configurations. + Encryption string `json:"encryption" mapstructure:"encryption" yaml:"encryption"` + Age chezmoi.AgeEncryption `json:"age" mapstructure:"age" yaml:"age"` + GPG chezmoi.GPGEncryption `json:"gpg" mapstructure:"gpg" yaml:"gpg"` + + // Command configurations. + Add addCmdConfig `json:"add" mapstructure:"add" yaml:"add"` + CD cdCmdConfig `json:"cd" mapstructure:"cd" yaml:"cd"` + Completion completionCmdConfig `json:"completion" mapstructure:"completion" yaml:"completion"` + Diff diffCmdConfig `json:"diff" mapstructure:"diff" yaml:"diff"` + Edit editCmdConfig `json:"edit" mapstructure:"edit" yaml:"edit"` + Git gitCmdConfig `json:"git" mapstructure:"git" yaml:"git"` + Merge mergeCmdConfig `json:"merge" mapstructure:"merge" yaml:"merge"` + Status statusCmdConfig `json:"status" mapstructure:"status" yaml:"status"` + Update updateCmdConfig `json:"update" mapstructure:"update" yaml:"update"` + Verify verifyCmdConfig `json:"verify" mapstructure:"verify" yaml:"verify"` } // A Config represents a configuration. type Config struct { - // Global configuration, settable in the config file. - CacheDirAbsPath chezmoi.AbsPath `mapstructure:"cacheDir"` - Color autoBool `mapstructure:"color"` - Data map[string]interface{} `mapstructure:"data"` - DestDirAbsPath chezmoi.AbsPath `mapstructure:"destDir"` - Interpreters map[string]*chezmoi.Interpreter `mapstructure:"interpreters"` - Mode chezmoi.Mode `mapstructure:"mode"` - Pager string `mapstructure:"pager"` - PINEntry pinEntryConfig `mapstructure:"pinentry"` - Safe bool `mapstructure:"safe"` - SourceDirAbsPath chezmoi.AbsPath `mapstructure:"sourceDir"` - Template templateConfig `mapstructure:"template"` - Umask fs.FileMode `mapstructure:"umask"` - UseBuiltinAge autoBool `mapstructure:"useBuiltinAge"` - UseBuiltinGit autoBool `mapstructure:"useBuiltinGit"` - Verbose bool `mapstructure:"verbose"` - WorkingTreeAbsPath chezmoi.AbsPath `mapstructure:"workingTree"` - - // Global configuration, not settable in the config file. + ConfigFile + + // Global configuration. configFormat readDataFormat cpuProfile chezmoi.AbsPath debug bool dryRun bool force bool - gops bool homeDir string + interactive bool keepGoing bool noPager bool noTTY bool outputAbsPath chezmoi.AbsPath - refreshExternals bool + refreshExternals chezmoi.RefreshExternals sourcePath 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"` + useBuiltinDiff bool // Password manager data. gitHub gitHubData keyring keyringData - // Command configurations, settable in the config file. - Add addCmdConfig `mapstructure:"add"` - CD cdCmdConfig `mapstructure:"cd"` - Diff diffCmdConfig `mapstructure:"diff"` - Docs docsCmdConfig `mapstructure:"docs"` - Edit editCmdConfig `mapstructure:"edit"` - Git gitCmdConfig `mapstructure:"git"` - Merge mergeCmdConfig `mapstructure:"merge"` - // Command configurations, not settable in the config file. + age ageCmdConfig apply applyCmdConfig archive archiveCmdConfig - data dataCmdConfig + chattr chattrCmdConfig + destroy destroyCmdConfig + doctor doctorCmdConfig dump dumpCmdConfig executeTemplate executeTemplateCmdConfig + ignored ignoredCmdConfig _import importCmdConfig init initCmdConfig managed managedCmdConfig mergeAll mergeAllCmdConfig purge purgeCmdConfig reAdd reAddCmdConfig - remove removeCmdConfig - secretKeyring secretKeyringCmdConfig + secret secretCmdConfig state stateCmdConfig - status statusCmdConfig - update updateCmdConfig + unmanaged unmanagedCmdConfig upgrade upgradeCmdConfig - verify verifyCmdConfig + + // Common configuration. + interactiveTemplateFuncs interactiveTemplateFuncsConfig // Version information. - version *semver.Version + version semver.Version versionInfo VersionInfo versionStr string // Configuration. - fileSystem vfs.FS - bds *xdg.BaseDirectorySpecification - configFileAbsPath chezmoi.AbsPath - baseSystem chezmoi.System - sourceSystem chezmoi.System - destSystem chezmoi.System - persistentStateAbsPath chezmoi.AbsPath - persistentState chezmoi.PersistentState - httpClient *http.Client - logger *zerolog.Logger + fileSystem vfs.FS + bds *xdg.BaseDirectorySpecification + defaultConfigFileAbsPath chezmoi.AbsPath + defaultConfigFileAbsPathErr error + customConfigFileAbsPath chezmoi.AbsPath + baseSystem chezmoi.System + sourceSystem chezmoi.System + destSystem chezmoi.System + persistentState chezmoi.PersistentState + httpClient *http.Client + logger *slog.Logger // Computed configuration. - homeDirAbsPath chezmoi.AbsPath - encryption chezmoi.Encryption - - stdin io.Reader - stdout io.Writer - stderr io.Writer + commandDirAbsPath chezmoi.AbsPath + homeDirAbsPath chezmoi.AbsPath + encryption chezmoi.Encryption + sourceDirAbsPath chezmoi.AbsPath + sourceDirAbsPathErr error + sourceState *chezmoi.SourceState + sourceStateErr error + templateData *templateData + gitleaksDetector *detect.Detector + gitleaksDetectorErr error + + stdin io.Reader + stdout io.Writer + stderr io.Writer + bufioReader *bufio.Reader + diffPagerCmdStdin io.WriteCloser + diffPagerCmd *exec.Cmd tempDirs map[string]chezmoi.AbsPath ioregData ioregData + + restoreWindowsConsole func() error +} + +type templateData struct { + arch string + args []string + cacheDir chezmoi.AbsPath + command string + commandDir chezmoi.AbsPath + config map[string]any + configFile chezmoi.AbsPath + executable chezmoi.AbsPath + fqdnHostname string + gid string + group string + homeDir chezmoi.AbsPath + hostname string + kernel map[string]any + os string + osRelease map[string]any + pathListSeparator string + pathSeparator string + sourceDir chezmoi.AbsPath + uid string + username string + version map[string]any + windowsVersion map[string]any + workingTree chezmoi.AbsPath } // A configOption sets and option on a Config. type configOption func(*Config) error type configState struct { - ConfigTemplateContentsSHA256 chezmoi.HexBytes `json:"configTemplateContentsSHA256" yaml:"configTemplateContentsSHA256"` //nolint:lll,tagliatelle + ConfigTemplateContentsSHA256 chezmoi.HexBytes `json:"configTemplateContentsSHA256" yaml:"configTemplateContentsSHA256"` //nolint:tagliatelle } var ( @@ -202,25 +307,20 @@ var ( Suffix: ".asc", } - identifierRx = regexp.MustCompile(`\A[\pL_][\pL\p{Nd}_]*\z`) whitespaceRx = regexp.MustCompile(`\s+`) - viperDecodeConfigOptions = []viper.DecoderConfigOption{ - viper.DecodeHook( - mapstructure.ComposeDecodeHookFunc( - mapstructure.StringToTimeDurationHookFunc(), - mapstructure.StringToSliceHookFunc(","), - chezmoi.StringSliceToEntryTypeSetHookFunc(), - chezmoi.StringToAbsPathHookFunc(), - StringOrBoolToAutoBoolHookFunc(), - ), - ), + commonFlagCompletionFuncs = map[string]func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective){ + "exclude": chezmoi.EntryTypeSetFlagCompletionFunc, + "format": writeDataFormatFlagCompletionFunc, + "include": chezmoi.EntryTypeSetFlagCompletionFunc, + "path-style": chezmoi.PathStyleFlagCompletionFunc, + "secrets": severityFlagCompletionFunc, } ) // newConfig creates a new Config with the given options. func newConfig(options ...configOption) (*Config, error) { - userHomeDir, err := os.UserHomeDir() + userHomeDir, err := chezmoi.UserHomeDir() if err != nil { return nil, err } @@ -234,110 +334,26 @@ func newConfig(options ...configOption) (*Config, error) { return nil, err } - cacheDirAbsPath := chezmoi.NewAbsPath(bds.CacheHome).Join(chezmoiRelPath) + logger := slog.Default() c := &Config{ - // Global configuration, settable in the config file. - CacheDirAbsPath: cacheDirAbsPath, - Color: autoBool{ - auto: true, - }, - Interpreters: defaultInterpreters, - Pager: os.Getenv("PAGER"), - PINEntry: pinEntryConfig{ - Options: pinEntryDefaultOptions, - }, - Safe: true, - Template: templateConfig{ - Options: chezmoi.DefaultTemplateOptions, - }, - Umask: chezmoi.Umask, - UseBuiltinAge: autoBool{ - auto: true, - }, - UseBuiltinGit: autoBool{ - auto: true, - }, + ConfigFile: newConfigFile(bds), - // Global configuration, not settable in the config file. + // Global configuration. homeDir: userHomeDir, templateFuncs: sprig.TxtFuncMap(), - // Password manager configurations, settable in the config file. - 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", - }, - - // Encryption configurations, settable in the config file. - Age: defaultAgeEncryptionConfig, - GPG: defaultGPGEncryptionConfig, - - // Password manager data. - - // Command configurations, settable in the config file. - Add: addCmdConfig{ - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), - recursive: true, - }, - Diff: diffCmdConfig{ - Exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), - }, - Docs: docsCmdConfig{ - MaxWidth: 80, - }, - Edit: editCmdConfig{ - Hardlink: true, - MinDuration: 1 * time.Second, - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - include: chezmoi.NewEntryTypeSet( - chezmoi.EntryTypeDirs | chezmoi.EntryTypeFiles | chezmoi.EntryTypeSymlinks | chezmoi.EntryTypeEncrypted, - ), - }, - Git: gitCmdConfig{ - Command: "git", - }, - Merge: mergeCmdConfig{ - Command: "vimdiff", - }, - - // Command configurations, not settable in the config file. + // Command configurations. apply: applyCmdConfig{ - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), recursive: true, }, archive: archiveCmdConfig{ - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), recursive: true, }, - data: dataCmdConfig{ - format: defaultWriteDataFormat, - }, dump: dumpCmdConfig{ - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - format: defaultWriteDataFormat, - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), recursive: true, }, executeTemplate: executeTemplateCmdConfig{ @@ -345,59 +361,33 @@ func newConfig(options ...configOption) (*Config, error) { }, _import: importCmdConfig{ destination: homeDirAbsPath, - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), }, init: initCmdConfig{ - data: true, - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), + data: true, + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + guessRepoURL: true, + recurseSubmodules: true, }, managed: managedCmdConfig{ - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - include: chezmoi.NewEntryTypeSet( - chezmoi.EntryTypeDirs | chezmoi.EntryTypeFiles | chezmoi.EntryTypeSymlinks | chezmoi.EntryTypeEncrypted, - ), + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + pathStyle: chezmoi.PathStyleRelative, }, mergeAll: mergeAllCmdConfig{ recursive: true, }, reAdd: reAddCmdConfig{ - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), - recursive: true, - }, - state: stateCmdConfig{ - data: stateDataCmdConfig{ - format: defaultWriteDataFormat, - }, - dump: stateDumpCmdConfig{ - format: defaultWriteDataFormat, - }, - }, - status: statusCmdConfig{ - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), - recursive: true, - }, - update: updateCmdConfig{ - apply: true, - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), recursive: true, }, - upgrade: upgradeCmdConfig{ - owner: "twpayne", - repo: "chezmoi", - }, - verify: verifyCmdConfig{ - exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll &^ chezmoi.EntryTypeScripts), - recursive: true, + unmanaged: unmanagedCmdConfig{ + pathStyle: chezmoi.PathStyleRelative, }, // Configuration. fileSystem: vfs.OSFS, bds: bds, + logger: logger, // Computed configuration. homeDirAbsPath: homeDirAbsPath, @@ -409,39 +399,99 @@ func newConfig(options ...configOption) (*Config, error) { stderr: os.Stderr, } - for key, value := range map[string]interface{}{ - "bitwarden": c.bitwardenTemplateFunc, - "bitwardenAttachment": c.bitwardenAttachmentTemplateFunc, - "bitwardenFields": c.bitwardenFieldsTemplateFunc, - "decrypt": c.decryptTemplateFunc, - "encrypt": c.encryptTemplateFunc, - "fromYaml": c.fromYamlTemplateFunc, - "gitHubKeys": c.gitHubKeysTemplateFunc, - "gitHubLatestRelease": c.gitHubLatestReleaseTemplateFunc, - "gopass": c.gopassTemplateFunc, - "gopassRaw": c.gopassRawTemplateFunc, - "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, - "mozillaInstallHash": c.mozillaInstallHashTemplateFunc, - "onepassword": c.onepasswordTemplateFunc, - "onepasswordDetailsFields": c.onepasswordDetailsFieldsTemplateFunc, - "onepasswordDocument": c.onepasswordDocumentTemplateFunc, - "onepasswordItemFields": c.onepasswordItemFieldsTemplateFunc, - "output": c.outputTemplateFunc, - "pass": c.passTemplateFunc, - "passRaw": c.passRawTemplateFunc, - "secret": c.secretTemplateFunc, - "secretJSON": c.secretJSONTemplateFunc, - "stat": c.statTemplateFunc, - "toYaml": c.toYamlTemplateFunc, - "vault": c.vaultTemplateFunc, + // Override sprig template functions. Delete them from the template function + // map first to avoid a duplicate function panic. + delete(c.templateFuncs, "fromJson") + delete(c.templateFuncs, "splitList") + delete(c.templateFuncs, "toPrettyJson") + + // The completion template function is added in persistentPreRunRootE as + // it needs a *cobra.Command, which we don't yet have. + for key, value := range map[string]any{ + "awsSecretsManager": c.awsSecretsManagerTemplateFunc, + "awsSecretsManagerRaw": c.awsSecretsManagerRawTemplateFunc, + "azureKeyVault": c.azureKeyVaultTemplateFunc, + "bitwarden": c.bitwardenTemplateFunc, + "bitwardenAttachment": c.bitwardenAttachmentTemplateFunc, + "bitwardenAttachmentByRef": c.bitwardenAttachmentByRefTemplateFunc, + "bitwardenFields": c.bitwardenFieldsTemplateFunc, + "bitwardenSecrets": c.bitwardenSecretsTemplateFunc, + "comment": c.commentTemplateFunc, + "dashlaneNote": c.dashlaneNoteTemplateFunc, + "dashlanePassword": c.dashlanePasswordTemplateFunc, + "decrypt": c.decryptTemplateFunc, + "deleteValueAtPath": c.deleteValueAtPathTemplateFunc, + "doppler": c.dopplerTemplateFunc, + "dopplerProjectJson": c.dopplerProjectJSONTemplateFunc, + "ejsonDecrypt": c.ejsonDecryptTemplateFunc, + "ejsonDecryptWithKey": c.ejsonDecryptWithKeyTemplateFunc, + "encrypt": c.encryptTemplateFunc, + "eqFold": c.eqFoldTemplateFunc, + "findExecutable": c.findExecutableTemplateFunc, + "findOneExecutable": c.findOneExecutableTemplateFunc, + "fromIni": c.fromIniTemplateFunc, + "fromJson": c.fromJsonTemplateFunc, + "fromJsonc": c.fromJsoncTemplateFunc, + "fromToml": c.fromTomlTemplateFunc, + "fromYaml": c.fromYamlTemplateFunc, + "gitHubKeys": c.gitHubKeysTemplateFunc, + "gitHubLatestRelease": c.gitHubLatestReleaseTemplateFunc, + "gitHubLatestReleaseAssetURL": c.gitHubLatestReleaseAssetURLTemplateFunc, + "gitHubLatestTag": c.gitHubLatestTagTemplateFunc, + "gitHubReleases": c.gitHubReleasesTemplateFunc, + "gitHubTags": c.gitHubTagsTemplateFunc, + "glob": c.globTemplateFunc, + "gopass": c.gopassTemplateFunc, + "gopassRaw": c.gopassRawTemplateFunc, + "hcpVaultSecret": c.hcpVaultSecretTemplateFunc, + "hcpVaultSecretJson": c.hcpVaultSecretJSONTemplateFunc, + "hexDecode": c.hexDecodeTemplateFunc, + "hexEncode": c.hexEncodeTemplateFunc, + "include": c.includeTemplateFunc, + "includeTemplate": c.includeTemplateTemplateFunc, + "ioreg": c.ioregTemplateFunc, + "isExecutable": c.isExecutableTemplateFunc, + "joinPath": c.joinPathTemplateFunc, + "jq": c.jqTemplateFunc, + "keepassxc": c.keepassxcTemplateFunc, + "keepassxcAttachment": c.keepassxcAttachmentTemplateFunc, + "keepassxcAttribute": c.keepassxcAttributeTemplateFunc, + "keeper": c.keeperTemplateFunc, + "keeperDataFields": c.keeperDataFieldsTemplateFunc, + "keeperFindPassword": c.keeperFindPasswordTemplateFunc, + "keyring": c.keyringTemplateFunc, + "lastpass": c.lastpassTemplateFunc, + "lastpassRaw": c.lastpassRawTemplateFunc, + "lookPath": c.lookPathTemplateFunc, + "lstat": c.lstatTemplateFunc, + "mozillaInstallHash": c.mozillaInstallHashTemplateFunc, + "onepassword": c.onepasswordTemplateFunc, + "onepasswordDetailsFields": c.onepasswordDetailsFieldsTemplateFunc, + "onepasswordDocument": c.onepasswordDocumentTemplateFunc, + "onepasswordItemFields": c.onepasswordItemFieldsTemplateFunc, + "onepasswordRead": c.onepasswordReadTemplateFunc, + "onepasswordSDKItemsGet": c.onepasswordSDKItemsGet, + "onepasswordSDKSecretsResolve": c.onepasswordSDKSecretsResolve, + "output": c.outputTemplateFunc, + "pass": c.passTemplateFunc, + "passFields": c.passFieldsTemplateFunc, + "passhole": c.passholeTemplateFunc, + "passRaw": c.passRawTemplateFunc, + "pruneEmptyDicts": c.pruneEmptyDictsTemplateFunc, + "quoteList": c.quoteListTemplateFunc, + "rbw": c.rbwTemplateFunc, + "rbwFields": c.rbwFieldsTemplateFunc, + "replaceAllRegex": c.replaceAllRegexTemplateFunc, + "secret": c.secretTemplateFunc, + "secretJSON": c.secretJSONTemplateFunc, + "setValueAtPath": c.setValueAtPathTemplateFunc, + "splitList": c.splitListTemplateFunc, + "stat": c.statTemplateFunc, + "toIni": c.toIniTemplateFunc, + "toPrettyJson": c.toPrettyJsonTemplateFunc, + "toToml": c.toTomlTemplateFunc, + "toYaml": c.toYamlTemplateFunc, + "vault": c.vaultTemplateFunc, } { c.addTemplateFunc(key, value) } @@ -452,14 +502,19 @@ func newConfig(options ...configOption) (*Config, error) { } } - c.homeDirAbsPath, err = chezmoi.NormalizePath(c.homeDir) + wd, err := os.Getwd() + if err != nil { + return nil, err + } + c.commandDirAbsPath, err = chezmoi.NormalizePath(wd) if err != nil { return nil, err } - c.configFileAbsPath, err = c.defaultConfigFile(c.fileSystem, c.bds) + c.homeDirAbsPath, err = chezmoi.NormalizePath(c.homeDir) if err != nil { return nil, err } + c.defaultConfigFileAbsPath, c.defaultConfigFileAbsPathErr = c.defaultConfigFile(c.fileSystem, c.bds) c.SourceDirAbsPath, err = c.defaultSourceDir(c.fileSystem, c.bds) if err != nil { return nil, err @@ -470,20 +525,41 @@ func newConfig(options ...configOption) (*Config, error) { return c, nil } -// addTemplateFunc adds the template function with the key key and value value +func (c *Config) getConfigFileAbsPath() chezmoi.AbsPath { + if c.customConfigFileAbsPath.Empty() { + return c.defaultConfigFileAbsPath + } + return c.customConfigFileAbsPath +} + +// Close closes resources associated with c. +func (c *Config) Close() error { + errs := make([]error, 0, len(c.tempDirs)) + for _, tempDirAbsPath := range c.tempDirs { + err := os.RemoveAll(tempDirAbsPath.String()) + chezmoilog.InfoOrError(c.logger, "RemoveAll", err, + chezmoilog.Stringer("tempDir", tempDirAbsPath), + ) + errs = append(errs, err) + } + pprof.StopCPUProfile() + return chezmoierrors.Combine(errs...) +} + +// addTemplateFunc adds the template function with the given key and value // to c. It panics if there is already an existing template function with the // same key. -func (c *Config) addTemplateFunc(key string, value interface{}) { +func (c *Config) addTemplateFunc(key string, value any) { if _, ok := c.templateFuncs[key]; ok { - panic(fmt.Sprintf("%s: already defined", key)) + panic(key + ": already defined") } c.templateFuncs[key] = value } type applyArgsOptions struct { - include *chezmoi.EntryTypeSet + cmd *cobra.Command + filter *chezmoi.EntryTypeFilter init bool - exclude *chezmoi.EntryTypeSet recursive bool umask fs.FileMode preApplyFunc chezmoi.PreApplyFunc @@ -494,22 +570,25 @@ type applyArgsOptions struct { // source state for each target entry in args. If args is empty then the source // state is applied to all target entries. func (c *Config) applyArgs( - ctx context.Context, targetSystem chezmoi.System, targetDirAbsPath chezmoi.AbsPath, args []string, + ctx context.Context, + targetSystem chezmoi.System, + targetDirAbsPath chezmoi.AbsPath, + args []string, options applyArgsOptions, ) error { if options.init { - if err := c.createAndReloadConfigFile(); err != nil { + if err := c.createAndReloadConfigFile(options.cmd); err != nil { return err } } var currentConfigTemplateContentsSHA256 []byte - configTemplateRelPath, _, configTemplateContents, err := c.findFirstConfigTemplate() + configTemplate, err := c.findConfigTemplate() if err != nil { return err } - if configTemplateRelPath != chezmoi.EmptyRelPath { - currentConfigTemplateContentsSHA256 = chezmoi.SHA256Sum(configTemplateContents) + if configTemplate != nil { + currentConfigTemplateContentsSHA256 = chezmoi.SHA256Sum(configTemplate.contents) } var previousConfigTemplateContentsSHA256 []byte if configStateData, err := c.persistentState.Get(chezmoi.ConfigStateBucket, configStateKey); err != nil { @@ -526,12 +605,12 @@ func (c *Config) applyArgs( bytes.Equal(currentConfigTemplateContentsSHA256, previousConfigTemplateContentsSHA256) if !configTemplateContentsUnchanged { if c.force { - if configTemplateRelPath == chezmoi.EmptyRelPath { + if configTemplate == nil { if err := c.persistentState.Delete(chezmoi.ConfigStateBucket, configStateKey); err != nil { return err } } else { - configStateValue, err := json.Marshal(configState{ + configStateValue, err := chezmoi.FormatJSON.Marshal(configState{ ConfigTemplateContentsSHA256: chezmoi.HexBytes(currentConfigTemplateContentsSHA256), }) if err != nil { @@ -541,12 +620,12 @@ func (c *Config) applyArgs( return err } } - } else { + } else if c.Warnings.ConfigFileTemplateHasChanged { c.errorf("warning: config file template has changed, run chezmoi init to regenerate config file\n") } } - sourceState, err := c.newSourceState(ctx) + sourceState, err := c.getSourceState(ctx, options.cmd) if err != nil { return err } @@ -561,9 +640,8 @@ func (c *Config) applyArgs( return err } default: - targetRelPaths, err = c.targetRelPaths(sourceState, args, targetRelPathsOptions{ - mustBeInSourceState: true, - recursive: options.recursive, + targetRelPaths, err = c.targetRelPaths(sourceState, args, &targetRelPathsOptions{ + recursive: options.recursive, }) if err != nil { return err @@ -571,50 +649,70 @@ func (c *Config) applyArgs( } applyOptions := chezmoi.ApplyOptions{ - Include: options.include.Sub(options.exclude), + Filter: options.filter, PreApplyFunc: options.preApplyFunc, Umask: options.umask, } keptGoingAfterErr := false for _, targetRelPath := range targetRelPaths { - switch err := sourceState.Apply( - targetSystem, c.destSystem, c.persistentState, targetDirAbsPath, targetRelPath, applyOptions, - ); { - case errors.Is(err, chezmoi.Skip): + switch err := sourceState.Apply(targetSystem, c.destSystem, c.persistentState, targetDirAbsPath, targetRelPath, applyOptions); { + case errors.Is(err, fs.SkipDir): continue - case err != nil && c.keepGoing: - c.errorf("%v\n", err) - keptGoingAfterErr = true case err != nil: - return err + err = fmt.Errorf("%s: %w", targetRelPath, err) + if c.keepGoing { + c.errorf("%v\n", err) + keptGoingAfterErr = true + } else { + return err + } } } + + switch err := sourceState.PostApply(targetSystem, c.persistentState, targetDirAbsPath, targetRelPaths); { + case err != nil && c.keepGoing: + c.errorf("%v\n", err) + keptGoingAfterErr = true + case err != nil: + return err + } + if keptGoingAfterErr { - return ExitCodeError(1) + return chezmoi.ExitCodeError(1) } return nil } -// close closes resources associated with c. -func (c *Config) close() error { - var err error - for _, tempDirAbsPath := range c.tempDirs { - err2 := os.RemoveAll(tempDirAbsPath.String()) - c.logger.Err(err2). - Stringer("tempDir", tempDirAbsPath). - Msg("RemoveAll") - err = multierr.Append(err, err2) +// checkVersion checks that chezmoi is at least the required version for the +// source state. +func (c *Config) checkVersion() error { + versionAbsPath := c.SourceDirAbsPath.JoinString(chezmoi.VersionName) + switch data, err := c.baseSystem.ReadFile(versionAbsPath); { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + return err + default: + minVersion, err := semver.NewVersion(strings.TrimSpace(string(data))) + if err != nil { + return fmt.Errorf("%s: %q: %w", versionAbsPath, data, err) + } + var zeroVersion semver.Version + if c.version != zeroVersion && c.version.LessThan(*minVersion) { + return &chezmoi.TooOldError{ + Need: *minVersion, + Have: c.version, + } + } } - pprof.StopCPUProfile() - agent.Close() - return err + return nil } // cmdOutput returns the of running the command name with args in dirAbsPath. func (c *Config) cmdOutput(dirAbsPath chezmoi.AbsPath, name string, args []string) ([]byte, error) { cmd := exec.Command(name, args...) + cmd.Stderr = os.Stderr if !dirAbsPath.Empty() { dirRawAbsPath, err := c.baseSystem.RawPath(dirAbsPath) if err != nil { @@ -622,7 +720,7 @@ func (c *Config) cmdOutput(dirAbsPath chezmoi.AbsPath, name string, args []strin } cmd.Dir = dirRawAbsPath.String() } - return c.baseSystem.IdempotentCmdOutput(cmd) + return chezmoilog.LogCmdOutput(slog.Default(), cmd) } // colorAutoFunc detects whether color should be used. @@ -638,117 +736,169 @@ func (c *Config) colorAutoFunc() bool { // createAndReloadConfigFile creates a config file if it there is a config file // template and reloads it. -func (c *Config) createAndReloadConfigFile() error { +func (c *Config) createAndReloadConfigFile(cmd *cobra.Command) error { + // Refresh the source directory, as there might be a .chezmoiroot file and + // the template data is set before .chezmoiroot is read. + sourceDirAbsPath, err := c.getSourceDirAbsPath(&getSourceDirAbsPathOptions{ + refresh: true, + }) + if err != nil { + return err + } + c.templateData.sourceDir = sourceDirAbsPath + os.Setenv("CHEZMOI_SOURCE_DIR", sourceDirAbsPath.String()) + // Find config template, execute it, and create config file. - configTemplateRelPath, ext, configTemplateContents, err := c.findFirstConfigTemplate() + configTemplate, err := c.findConfigTemplate() if err != nil { return err } - var configFileContents []byte - if configTemplateRelPath == chezmoi.EmptyRelPath { - if err := c.persistentState.Delete(chezmoi.ConfigStateBucket, configStateKey); err != nil { - return err - } - } else { - configFileContents, err = c.createConfigFile(configTemplateRelPath, configTemplateContents) - if err != nil { - return err - } - // Validate the config. - v := viper.New() - v.SetConfigType(ext) - if err := v.ReadConfig(bytes.NewBuffer(configFileContents)); err != nil { - return err - } - if err := v.Unmarshal(&Config{}, viperDecodeConfigOptions...); err != nil { - return err - } + if configTemplate == nil { + return c.persistentState.Delete(chezmoi.ConfigStateBucket, configStateKey) + } - // Write the config. - configPath := c.init.configPath - if c.init.configPath.Empty() { - configPath = chezmoi.NewAbsPath(c.bds.ConfigHome).Join(chezmoiRelPath, configTemplateRelPath) - } - if err := chezmoi.MkdirAll(c.baseSystem, configPath.Dir(), 0o777); err != nil { - return err - } - if err := c.baseSystem.WriteFile(configPath, configFileContents, 0o600); err != nil { - return err - } + configFileContents, err := c.createConfigFile(configTemplate.targetRelPath, configTemplate.contents, cmd) + if err != nil { + return err + } - configStateValue, err := json.Marshal(configState{ - ConfigTemplateContentsSHA256: chezmoi.HexBytes(chezmoi.SHA256Sum(configTemplateContents)), - }) - if err != nil { - return err - } - if err := c.persistentState.Set(chezmoi.ConfigStateBucket, configStateKey, configStateValue); err != nil { - return err - } + // Validate the config file. + var configFile ConfigFile + if err := c.decodeConfigBytes(configTemplate.format, configFileContents, &configFile); err != nil { + return fmt.Errorf("%s: %w", configTemplate.sourceAbsPath, err) } - // Reload config if it was created. - if configTemplateRelPath != chezmoi.EmptyRelPath { - viper.SetConfigType(ext) - if err := viper.ReadConfig(bytes.NewBuffer(configFileContents)); err != nil { - return err - } - if err := viper.Unmarshal(c, viperDecodeConfigOptions...); err != nil { - return err + // Write the config. + configPath := c.init.configPath + if c.init.configPath.Empty() { + if c.customConfigFileAbsPath.Empty() { + configPath = chezmoi.NewAbsPath(c.bds.ConfigHome).Join(chezmoiRelPath, configTemplate.targetRelPath) + } else { + configPath = c.customConfigFileAbsPath } } + if err := chezmoi.MkdirAll(c.baseSystem, configPath.Dir(), fs.ModePerm); err != nil { + return err + } + if err := c.baseSystem.WriteFile(configPath, configFileContents, 0o600); err != nil { + return err + } + + configStateValue, err := chezmoi.FormatJSON.Marshal(configState{ + ConfigTemplateContentsSHA256: chezmoi.HexBytes(chezmoi.SHA256Sum(configTemplate.contents)), + }) + if err != nil { + return err + } + if err := c.persistentState.Set(chezmoi.ConfigStateBucket, configStateKey, configStateValue); err != nil { + return err + } + + // Reload the config. + if err := c.decodeConfigBytes(configTemplate.format, configFileContents, &c.ConfigFile); err != nil { + return fmt.Errorf("%s: %w", configTemplate.sourceAbsPath, err) + } + + if err := c.setEncryption(); err != nil { + return err + } + + if err := c.setEnvironmentVariables(); 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) { +func (c *Config) createConfigFile(filename chezmoi.RelPath, data []byte, cmd *cobra.Command) ([]byte, error) { + // Clone funcMap and restore it after creating the config. + // This ensures that the init template functions + // are removed before "normal" template parsing. funcMap := make(template.FuncMap) chezmoi.RecursiveMerge(funcMap, c.templateFuncs) - chezmoi.RecursiveMerge(funcMap, map[string]interface{}{ - "promptBool": c.promptBool, - "promptInt": c.promptInt, - "promptString": c.promptString, - "stdinIsATTY": c.stdinIsATTY, - "writeToStdout": c.writeToStdout, + defer func() { + c.templateFuncs = funcMap + }() + + initTemplateFuncs := map[string]any{ + "exit": c.exitInitTemplateFunc, + "promptBool": c.promptBoolInteractiveTemplateFunc, + "promptBoolOnce": c.promptBoolOnceInteractiveTemplateFunc, + "promptChoice": c.promptChoiceInteractiveTemplateFunc, + "promptChoiceOnce": c.promptChoiceOnceInteractiveTemplateFunc, + "promptInt": c.promptIntInteractiveTemplateFunc, + "promptIntOnce": c.promptIntOnceInteractiveTemplateFunc, + "promptString": c.promptStringInteractiveTemplateFunc, + "promptStringOnce": c.promptStringOnceInteractiveTemplateFunc, + "stdinIsATTY": c.stdinIsATTYInitTemplateFunc, + "writeToStdout": c.writeToStdout, + } + chezmoi.RecursiveMerge(c.templateFuncs, initTemplateFuncs) + + tmpl, err := chezmoi.ParseTemplate(filename.String(), data, c.templateFuncs, chezmoi.TemplateOptions{ + Options: slices.Clone(c.Template.Options), }) - - t, err := template.New(filename.String()).Funcs(funcMap).Parse(string(data)) if err != nil { return nil, err } - builder := strings.Builder{} - templateData := c.defaultTemplateData() + templateData := c.getTemplateDataMap(cmd) if c.init.data { chezmoi.RecursiveMerge(templateData, c.Data) } - if err = t.Execute(&builder, templateData); err != nil { - return nil, err - } - return []byte(builder.String()), nil + return tmpl.Execute(templateData) } // defaultConfigFile returns the default config file according to the XDG Base // Directory Specification. -func (c *Config) defaultConfigFile( - fileSystem vfs.Stater, bds *xdg.BaseDirectorySpecification, -) (chezmoi.AbsPath, error) { +func (c *Config) defaultConfigFile(fileSystem vfs.FS, bds *xdg.BaseDirectorySpecification) (chezmoi.AbsPath, error) { // Search XDG Base Directory Specification config directories first. +CONFIG_DIR: for _, configDir := range bds.ConfigDirs { configDirAbsPath, err := chezmoi.NewAbsPathFromExtPath(configDir, c.homeDirAbsPath) if err != nil { return chezmoi.EmptyAbsPath, err } - for _, extension := range viper.SupportedExts { - configFileAbsPath := configDirAbsPath.JoinString("chezmoi", "chezmoi."+extension) - if _, err := fileSystem.Stat(configFileAbsPath.String()); err == nil { - return configFileAbsPath, nil + + dirEntries, err := fileSystem.ReadDir(configDirAbsPath.JoinString("chezmoi").String()) + switch { + case errors.Is(err, fs.ErrNotExist): + continue CONFIG_DIR + case err != nil: + return chezmoi.EmptyAbsPath, err + } + + dirEntryNames := chezmoiset.NewWithCapacity[string](len(dirEntries)) + for _, dirEntry := range dirEntries { + dirEntryNames.Add(dirEntry.Name()) + } + + var names []string + for _, extension := range chezmoi.FormatExtensions { + name := "chezmoi." + extension + if dirEntryNames.Contains(name) { + names = append(names, name) + } + } + + switch len(names) { + case 0: + // Do nothing. + case 1: + return configDirAbsPath.JoinString("chezmoi", names[0]), nil + default: + configFileAbsPathStrs := make([]string, 0, len(names)) + for _, name := range names { + configFileAbsPathStr := configDirAbsPath.JoinString("chezmoi", name) + configFileAbsPathStrs = append(configFileAbsPathStrs, configFileAbsPathStr.String()) } + return chezmoi.EmptyAbsPath, fmt.Errorf("multiple config files: %s", englishList(configFileAbsPathStrs)) } } + // Fallback to XDG Base Directory Specification default. configHomeAbsPath, err := chezmoi.NewAbsPathFromExtPath(bds.ConfigHome, c.homeDirAbsPath) if err != nil { @@ -757,26 +907,125 @@ func (c *Config) defaultConfigFile( return configHomeAbsPath.JoinString("chezmoi", "chezmoi.toml"), nil } +// decodeConfigBytes decodes data in format into configFile. +func (c *Config) decodeConfigBytes(format chezmoi.Format, data []byte, configFile *ConfigFile) error { + var configMap map[string]any + if err := format.Unmarshal(data, &configMap); err != nil { + return err + } + return c.decodeConfigMap(configMap, configFile) +} + +// decodeConfigFile decodes the config file at configFileAbsPath into +// configFile. +func (c *Config) decodeConfigFile(configFileAbsPath chezmoi.AbsPath, configFile *ConfigFile) error { + var format chezmoi.Format + if c.configFormat == "" { + var err error + format, err = chezmoi.FormatFromAbsPath(configFileAbsPath) + if err != nil { + return err + } + } else { + format = c.configFormat.Format() + } + + configFileContents, err := c.fileSystem.ReadFile(configFileAbsPath.String()) + if err != nil { + return fmt.Errorf("%s: %w", configFileAbsPath, err) + } + + if err := c.decodeConfigBytes(format, configFileContents, configFile); err != nil { + return fmt.Errorf("%s: %w", configFileAbsPath, err) + } + + if configFile.Git.CommitMessageTemplate != "" && configFile.Git.CommitMessageTemplateFile != "" { + return fmt.Errorf( + "%s: cannot specify both git.commitMessageTemplate and git.commitMessageTemplateFile", + configFileAbsPath, + ) + } + + return nil +} + +// decodeConfigMap decodes configMap into configFile. +func (c *Config) decodeConfigMap(configMap map[string]any, configFile *ConfigFile) error { + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + DecodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + chezmoi.StringSliceToEntryTypeSetHookFunc(), + chezmoi.StringToAbsPathHookFunc(), + StringOrBoolToAutoBoolHookFunc(), + ), + Result: configFile, + }) + if err != nil { + return err + } + return decoder.Decode(configMap) +} + // defaultPreApplyFunc is the default pre-apply function. If the target entry // has changed since chezmoi last wrote it then it prompts the user for the // action to take. func (c *Config) defaultPreApplyFunc( - targetRelPath chezmoi.RelPath, targetEntryState, lastWrittenEntryState, actualEntryState *chezmoi.EntryState, + targetRelPath chezmoi.RelPath, + targetEntryState, lastWrittenEntryState, actualEntryState *chezmoi.EntryState, ) error { - c.logger.Info(). - Stringer("targetRelPath", targetRelPath). - Object("targetEntryState", targetEntryState). - Object("lastWrittenEntryState", lastWrittenEntryState). - Object("actualEntryState", actualEntryState). - Msg("defaultPreApplyFunc") + c.logger.Info("defaultPreApplyFunc", + chezmoilog.Stringer("targetRelPath", targetRelPath), + slog.Any("targetEntryState", targetEntryState), + slog.Any("lastWrittenEntryState", lastWrittenEntryState), + slog.Any("actualEntryState", actualEntryState), + ) + + switch { + case c.force: + return nil + case targetEntryState.Equivalent(actualEntryState): + return nil + } + + if c.interactive { + prompt := fmt.Sprintf("Apply %s", targetRelPath) + var choices []string + actualContents := actualEntryState.Contents() + targetContents := targetEntryState.Contents() + if actualContents != nil || targetContents != nil { + choices = append(choices, "diff") + } + choices = append(choices, choicesYesNoAllQuit...) + for { + switch choice, err := c.promptChoice(prompt, choices); { + case err != nil: + return err + case choice == "diff": + err := c.diffFile(targetRelPath, actualContents, actualEntryState.Mode, targetContents, targetEntryState.Mode) + if err != nil { + return err + } + case choice == "yes": + return nil + case choice == "no": + return fs.SkipDir + case choice == "all": + c.interactive = false + return nil + case choice == "quit": + return chezmoi.ExitCodeError(0) + default: + panic(choice + ": unexpected choice") + } + } + } switch { case targetEntryState.Overwrite(): return nil case targetEntryState.Type == chezmoi.EntryStateTypeScript: return nil - case c.force: - return nil case lastWrittenEntryState == nil: return nil case lastWrittenEntryState.Equivalent(actualEntryState): @@ -796,11 +1045,7 @@ func (c *Config) defaultPreApplyFunc( case err != nil: return err case choice == "diff": - if err := c.diffFile( - targetRelPath, - actualContents, actualEntryState.Mode, - targetContents, targetEntryState.Mode, - ); err != nil { + if err := c.diffFile(targetRelPath, actualContents, actualEntryState.Mode, targetContents, targetEntryState.Mode); err != nil { return err } case choice == "overwrite": @@ -809,11 +1054,11 @@ func (c *Config) defaultPreApplyFunc( c.force = true return nil case choice == "skip": - return chezmoi.Skip + return fs.SkipDir case choice == "quit": - return ExitCodeError(1) + return chezmoi.ExitCodeError(0) default: - return nil + panic(choice + ": unexpected choice") } } } @@ -840,116 +1085,10 @@ func (c *Config) defaultSourceDir(fileSystem vfs.Stater, bds *xdg.BaseDirectoryS return dataHomeAbsPath.Join(chezmoiRelPath), nil } -// defaultTemplateData returns the default template data. -func (c *Config) defaultTemplateData() map[string]interface{} { - // 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. - // - // On Windows, the user's group ID returned by user.Current() is an SID and - // no further useful lookup is possible with Go's standard library. - // - // 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. - var username, group string - if currentUser, err := user.Current(); err == nil { - username = currentUser.Username - if runtime.GOOS != "windows" { - if rawGroup, err := user.LookupGroupId(currentUser.Gid); err == nil { - group = rawGroup.Name - } else { - c.logger.Info(). - Str("gid", currentUser.Gid). - Err(err). - Msg("user.LookupGroupId") - } - } - } else { - c.logger.Info(). - Err(err). - Msg("user.Current") - var ok bool - username, ok = os.LookupEnv("USER") - if !ok { - c.logger.Info(). - Str("key", "USER"). - Bool("ok", ok). - Msg("os.LookupEnv") - } - } - - fqdnHostname := chezmoi.FQDNHostname(c.fileSystem) - - var hostname string - if rawHostname, err := os.Hostname(); err == nil { - hostname = strings.SplitN(rawHostname, ".", 2)[0] - } else { - c.logger.Info(). - Err(err). - Msg("os.Hostname") - } - - kernel, err := chezmoi.Kernel(c.fileSystem) - if err != nil { - c.logger.Info(). - Err(err). - Msg("chezmoi.Kernel") - } - - var osRelease map[string]interface{} - if rawOSRelease, err := chezmoi.OSRelease(c.baseSystem); err == nil { - osRelease = upperSnakeCaseToCamelCaseMap(rawOSRelease) - } else { - c.logger.Info(). - Err(err). - Msg("chezmoi.OSRelease") - } - - executable, _ := os.Executable() - - return map[string]interface{}{ - "chezmoi": map[string]interface{}{ - "arch": runtime.GOARCH, - "args": os.Args, - "executable": executable, - "fqdnHostname": fqdnHostname, - "group": group, - "homeDir": c.homeDir, - "hostname": hostname, - "kernel": kernel, - "os": runtime.GOOS, - "osRelease": osRelease, - "sourceDir": c.SourceDirAbsPath.String(), - "username": username, - "version": map[string]interface{}{ - "builtBy": c.versionInfo.BuiltBy, - "commit": c.versionInfo.Commit, - "date": c.versionInfo.Date, - "version": c.versionInfo.Version, - }, - }, - } -} - type destAbsPathInfosOptions struct { follow bool ignoreNotExist bool + onIgnoreFunc func(chezmoi.RelPath) recursive bool } @@ -957,7 +1096,9 @@ type destAbsPathInfosOptions struct { // args, recursing into subdirectories and following symlinks if configured in // options. func (c *Config) destAbsPathInfos( - sourceState *chezmoi.SourceState, args []string, options destAbsPathInfosOptions, + sourceState *chezmoi.SourceState, + args []string, + options destAbsPathInfosOptions, ) (map[chezmoi.AbsPath]fs.FileInfo, error) { destAbsPathInfos := make(map[chezmoi.AbsPath]fs.FileInfo) for _, arg := range args { @@ -966,9 +1107,14 @@ func (c *Config) destAbsPathInfos( if err != nil { return nil, err } - if _, err := destAbsPath.TrimDirPrefix(c.DestDirAbsPath); err != nil { + targetRelPath, err := c.targetRelPath(destAbsPath) + if err != nil { return nil, err } + if sourceState.Ignore(targetRelPath) { + options.onIgnoreFunc(targetRelPath) + continue + } if options.recursive { walkFunc := func(destAbsPath chezmoi.AbsPath, fileInfo fs.FileInfo, err error) error { switch { @@ -977,12 +1123,26 @@ func (c *Config) destAbsPathInfos( case err != nil: return err } + + targetRelPath, err := c.targetRelPath(destAbsPath) + if err != nil { + return err + } + if sourceState.Ignore(targetRelPath) { + options.onIgnoreFunc(targetRelPath) + if fileInfo.IsDir() { + return fs.SkipDir + } + return nil + } + if options.follow && fileInfo.Mode().Type() == fs.ModeSymlink { fileInfo, err = c.destSystem.Stat(destAbsPath) if err != nil { return err } } + return sourceState.AddDestAbsPathInfos(destAbsPathInfos, c.destSystem, destAbsPath, fileInfo) } if err := chezmoi.Walk(c.destSystem, destAbsPath, walkFunc); err != nil { @@ -1013,8 +1173,10 @@ func (c *Config) destAbsPathInfos( // at path. func (c *Config) diffFile( path chezmoi.RelPath, - fromData []byte, fromMode fs.FileMode, - toData []byte, toMode fs.FileMode, + fromData []byte, + fromMode fs.FileMode, + toData []byte, + toMode fs.FileMode, ) error { builder := strings.Builder{} unifiedEncoder := diff.NewUnifiedEncoder(&builder, diff.DefaultContextLines) @@ -1022,6 +1184,20 @@ func (c *Config) diffFile( if color { unifiedEncoder.SetColor(diff.NewColorConfig()) } + if fromMode.IsRegular() { + var err error + fromData, err = c.TextConv.convert(path.String(), fromData) + if err != nil { + return err + } + } + if toMode.IsRegular() { + var err error + toData, err = c.TextConv.convert(path.String(), toData) + if err != nil { + return err + } + } diffPatch, err := chezmoi.DiffPatch(path, fromData, fromMode, toData, toMode) if err != nil { return err @@ -1029,43 +1205,27 @@ func (c *Config) diffFile( if err := unifiedEncoder.Encode(diffPatch); err != nil { return err } - return c.pageOutputString(builder.String(), c.Diff.Pager) + return c.pageDiffOutput(builder.String()) } // editor returns the path to the user's editor and any extra arguments. -func (c *Config) editor(args []string) (string, []string) { - // If the user has set and edit command then use it. - if c.Edit.Command != "" { - return c.Edit.Command, append(c.Edit.Args, args...) - } - - // Prefer $VISUAL over $EDITOR and fallback to the OS's default editor. - editor := firstNonEmptyString( - os.Getenv("VISUAL"), - os.Getenv("EDITOR"), - defaultEditor, - ) +func (c *Config) editor(args []string) (string, []string, error) { + editCommand := c.Edit.Command + editArgs := c.Edit.Args - // If editor is found, return it. - if path, err := exec.LookPath(editor); err == nil { - return path, args + // If the user has set an edit command then use it. + if editCommand != "" { + return editCommand, append(editArgs, args...), 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, append(components[1:], args...) - } - } + // Prefer $VISUAL over $EDITOR and fallback to the OS's default editor. + editCommand = firstNonEmptyString(os.Getenv("VISUAL"), os.Getenv("EDITOR"), defaultEditor) - // Fallback to editor only. - return editor, args + return parseCommand(editCommand, append(editArgs, args...)) } // errorf writes an error to stderr. -func (c *Config) errorf(format string, args ...interface{}) { +func (c *Config) errorf(format string, args ...any) { fmt.Fprintf(c.stderr, "chezmoi: "+format, args...) } @@ -1076,6 +1236,7 @@ func (c *Config) execute(args []string) error { return err } rootCmd.SetArgs(args) + return rootCmd.Execute() } @@ -1115,26 +1276,114 @@ func (c *Config) filterInput(args []string, f func([]byte) ([]byte, error)) erro return nil } -// findFirstConfigTemplate searches for a config template, returning the path, -// format, and contents of the first one that it finds. -func (c *Config) findFirstConfigTemplate() (chezmoi.RelPath, string, []byte, error) { - sourceDirAbsPath, err := c.sourceDirAbsPath() +type configTemplate struct { + sourceAbsPath chezmoi.AbsPath + format chezmoi.Format + targetRelPath chezmoi.RelPath + contents []byte +} + +// findConfigTemplate searches for a config template, returning the path, +// format, and contents. It returns an error if multiple config file templates +// are found. +func (c *Config) findConfigTemplate() (*configTemplate, error) { + sourceDirAbsPath, err := c.getSourceDirAbsPath(nil) if err != nil { - return chezmoi.EmptyRelPath, "", nil, err + return nil, err } - for _, ext := range viper.SupportedExts { - filename := chezmoi.NewRelPath(chezmoi.Prefix + "." + ext + chezmoi.TemplateSuffix) - contents, err := c.baseSystem.ReadFile(sourceDirAbsPath.Join(filename)) - switch { - case errors.Is(err, fs.ErrNotExist): + dirEntries, err := c.baseSystem.ReadDir(sourceDirAbsPath) + switch { + case errors.Is(err, fs.ErrNotExist): + return nil, nil + case err != nil: + return nil, err + } + + dirEntryNames := chezmoiset.NewWithCapacity[chezmoi.RelPath](len(dirEntries)) + for _, dirEntry := range dirEntries { + dirEntryNames.Add(chezmoi.NewRelPath(dirEntry.Name())) + } + + var configTemplates []*configTemplate //nolint:prealloc + for _, extension := range chezmoi.FormatExtensions { + relPath := chezmoi.NewRelPath(chezmoi.Prefix + "." + extension + chezmoi.TemplateSuffix) + if !dirEntryNames.Contains(relPath) { continue - case err != nil: - return chezmoi.EmptyRelPath, "", nil, err } - return chezmoi.NewRelPath("chezmoi." + ext), ext, contents, nil + absPath := sourceDirAbsPath.Join(relPath) + contents, err := c.baseSystem.ReadFile(absPath) + if err != nil { + return nil, err + } + configTemplate := &configTemplate{ + targetRelPath: chezmoi.NewRelPath("chezmoi." + extension), + format: chezmoi.FormatsByExtension[extension], + sourceAbsPath: absPath, + contents: contents, + } + configTemplates = append(configTemplates, configTemplate) + } + + switch len(configTemplates) { + case 0: + return nil, nil + case 1: + return configTemplates[0], nil + default: + sourceAbsPathStrs := make([]string, len(configTemplates)) + for i, configTemplate := range configTemplates { + sourceAbsPathStr := configTemplate.sourceAbsPath.String() + sourceAbsPathStrs[i] = sourceAbsPathStr + } + return nil, fmt.Errorf("multiple config file templates: %s ", englishList(sourceAbsPathStrs)) } - return chezmoi.EmptyRelPath, "", nil, nil +} + +// getDiffPager returns the pager for diff output. +func (c *Config) getDiffPager() string { + switch { + case c.noPager: + return "" + case c.Diff.Pager != defaultSentinel: + return c.Diff.Pager + default: + return c.Pager + } +} + +// getDiffPagerCmd returns a command to run the diff pager, or nil if there is +// no diff pager configured. +func (c *Config) getDiffPagerCmd() (*exec.Cmd, error) { + pager := c.getDiffPager() + if pager == "" { + return nil, nil + } + + // If the pager command contains any spaces, assume that it is a full + // shell command to be executed via the user's shell. Otherwise, execute + // it directly. + var pagerCmd *exec.Cmd + if strings.IndexFunc(pager, unicode.IsSpace) != -1 { + shellCommand, _ := shell.CurrentUserShell() + shellCommand, shellArgs, err := parseCommand(shellCommand, []string{"-c", pager}) + if err != nil { + return nil, err + } + pagerCmd = exec.Command(shellCommand, shellArgs...) + } else { + pagerCmd = exec.Command(pager) + } + pagerCmd.Stdout = c.stdout + pagerCmd.Stderr = c.stderr + return pagerCmd, nil +} + +func (c *Config) getGitleaksDetector() (*detect.Detector, error) { + if c.gitleaksDetector == nil && c.gitleaksDetectorErr == nil { + c.gitleaksDetector, c.gitleaksDetectorErr = detect.NewDetectorDefaultConfig() + } + return c.gitleaksDetector, c.gitleaksDetectorErr } func (c *Config) getHTTPClient() (*http.Client, error) { @@ -1153,8 +1402,83 @@ func (c *Config) getHTTPClient() (*http.Client, error) { return c.httpClient, nil } +type getSourceDirAbsPathOptions struct { + refresh bool +} + +// getSourceDirAbsPath returns the source directory, using .chezmoiroot if it +// exists. +func (c *Config) getSourceDirAbsPath(options *getSourceDirAbsPathOptions) (chezmoi.AbsPath, error) { + if options == nil || !options.refresh { + if !c.sourceDirAbsPath.Empty() || c.sourceDirAbsPathErr != nil { + return c.sourceDirAbsPath, c.sourceDirAbsPathErr + } + } + + switch data, err := c.sourceSystem.ReadFile(c.SourceDirAbsPath.JoinString(chezmoi.RootName)); { + case errors.Is(err, fs.ErrNotExist): + c.sourceDirAbsPath = c.SourceDirAbsPath + case err != nil: + c.sourceDirAbsPathErr = err + default: + c.sourceDirAbsPath = c.SourceDirAbsPath.JoinString(string(bytes.TrimSpace(data))) + } + + return c.sourceDirAbsPath, c.sourceDirAbsPathErr +} + +func (c *Config) getSourceState(ctx context.Context, cmd *cobra.Command) (*chezmoi.SourceState, error) { + if c.sourceState != nil || c.sourceStateErr != nil { + return c.sourceState, c.sourceStateErr + } + c.sourceState, c.sourceStateErr = c.newSourceState(ctx, cmd) + return c.sourceState, c.sourceStateErr +} + +// getTemplateData returns the default template data. +func (c *Config) getTemplateData(cmd *cobra.Command) *templateData { + if c.templateData == nil { + c.templateData = c.newTemplateData(cmd) + } + return c.templateData +} + +// getTemplateDataMap returns the template data as a map. +func (c *Config) getTemplateDataMap(cmd *cobra.Command) map[string]any { + templateData := c.getTemplateData(cmd) + + return map[string]any{ + "chezmoi": map[string]any{ + "arch": templateData.arch, + "args": templateData.args, + "cacheDir": templateData.cacheDir.String(), + "command": templateData.command, + "commandDir": templateData.commandDir.String(), + "config": templateData.config, + "configFile": templateData.configFile.String(), + "executable": templateData.executable.String(), + "fqdnHostname": templateData.fqdnHostname, + "gid": templateData.gid, + "group": templateData.group, + "homeDir": templateData.homeDir.String(), + "hostname": templateData.hostname, + "kernel": templateData.kernel, + "os": templateData.os, + "osRelease": templateData.osRelease, + "pathListSeparator": templateData.pathListSeparator, + "pathSeparator": templateData.pathSeparator, + "sourceDir": templateData.sourceDir.String(), + "uid": templateData.uid, + "username": templateData.username, + "version": templateData.version, + "windowsVersion": templateData.windowsVersion, + "workingTree": templateData.workingTree.String(), + }, + } +} + // gitAutoAdd adds all changes to the git index and returns the new git status. -func (c *Config) gitAutoAdd() (*git.Status, error) { +func (c *Config) gitAutoAdd() (*chezmoigit.Status, error) { if err := c.run(c.WorkingTreeAbsPath, c.Git.Command, []string{"add", "."}); err != nil { return nil, err } @@ -1162,45 +1486,85 @@ func (c *Config) gitAutoAdd() (*git.Status, error) { if err != nil { return nil, err } - return git.ParseStatusPorcelainV2(output) + return chezmoigit.ParseStatusPorcelainV2(output) } // gitAutoCommit commits all changes in the git index, including generating a // commit message from status. -func (c *Config) gitAutoCommit(status *git.Status) error { +func (c *Config) gitAutoCommit(cmd *cobra.Command, status *chezmoigit.Status) error { if status.Empty() { return nil } - commitMessageTemplate, err := templates.FS.ReadFile("COMMIT_MESSAGE.tmpl") - if err != nil { - return err - } - commitMessageTmpl, err := template.New("commit_message").Funcs(c.templateFuncs).Parse(string(commitMessageTemplate)) + commitMessage, err := c.gitCommitMessage(cmd, status) if err != nil { return err } - commitMessage := strings.Builder{} - if err := commitMessageTmpl.Execute(&commitMessage, status); err != nil { - return err - } - return c.run(c.WorkingTreeAbsPath, c.Git.Command, []string{"commit", "--message", commitMessage.String()}) + return c.run(c.WorkingTreeAbsPath, c.Git.Command, []string{"commit", "--message", string(commitMessage)}) } // gitAutoPush pushes all changes to the remote if status is not empty. -func (c *Config) gitAutoPush(status *git.Status) error { +func (c *Config) gitAutoPush(status *chezmoigit.Status) error { if status.Empty() { return nil } return c.run(c.WorkingTreeAbsPath, c.Git.Command, []string{"push"}) } +// gitCommitMessage returns the git commit message for the given status. +func (c *Config) gitCommitMessage(cmd *cobra.Command, status *chezmoigit.Status) ([]byte, error) { + funcMap := maps.Clone(c.templateFuncs) + maps.Copy(funcMap, map[string]any{ + "promptBool": c.promptBoolInteractiveTemplateFunc, + "promptChoice": c.promptChoiceInteractiveTemplateFunc, + "promptInt": c.promptIntInteractiveTemplateFunc, + "promptString": c.promptStringInteractiveTemplateFunc, + "targetRelPath": func(source string) string { + return chezmoi.NewSourceRelPath(source).TargetRelPath(c.encryption.EncryptedSuffix()).String() + }, + }) + var name string + var commitMessageTemplateData []byte + switch { + case c.Git.CommitMessageTemplate != "": + name = "git.commitMessageTemplate" + commitMessageTemplateData = []byte(c.Git.CommitMessageTemplate) + case c.Git.CommitMessageTemplateFile != "": + if c.sourceDirAbsPathErr != nil { + return nil, c.sourceDirAbsPathErr + } + commitMessageTemplateFileAbsPath := c.sourceDirAbsPath.JoinString(c.Git.CommitMessageTemplateFile) + name = c.sourceDirAbsPath.String() + var err error + commitMessageTemplateData, err = c.baseSystem.ReadFile(commitMessageTemplateFileAbsPath) + if err != nil { + return nil, err + } + default: + name = "COMMIT_MESSAGE" + commitMessageTemplateData = []byte(templates.CommitMessageTmpl) + } + commitMessageTmpl, err := chezmoi.ParseTemplate(name, commitMessageTemplateData, funcMap, chezmoi.TemplateOptions{ + Options: slices.Clone(c.Template.Options), + }) + if err != nil { + return nil, err + } + sourceState, err := c.getSourceState(cmd.Context(), cmd) + if err != nil { + return nil, err + } + templateDataMap := sourceState.TemplateData() + templateDataMap["chezmoi"].(map[string]any)["status"] = status //nolint:forcetypeassert + return commitMessageTmpl.Execute(templateDataMap) +} + // makeRunEWithSourceState returns a function for // github.com/spf13/cobra.Command.RunE that includes reading the source state. 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.newSourceState(cmd.Context()) + sourceState, err := c.getSourceState(cmd.Context(), cmd) if err != nil { return err } @@ -1209,17 +1573,8 @@ func (c *Config) makeRunEWithSourceState( } // marshal formats data in dataFormat and writes it to the standard output. -func (c *Config) marshal(dataFormat writeDataFormat, data interface{}) error { - var format chezmoi.Format - switch dataFormat { - case writeDataFormatJSON: - format = chezmoi.FormatJSON - case writeDataFormatYAML: - format = chezmoi.FormatYAML - default: - return fmt.Errorf("%s: unknown format", dataFormat) - } - marshaledData, err := format.Marshal(data) +func (c *Config) marshal(dataFormat writeDataFormat, data any) error { + marshaledData, err := dataFormat.Format().Marshal(data) if err != nil { return err } @@ -1240,88 +1595,85 @@ func (c *Config) newRootCmd() (*cobra.Command, error) { persistentFlags := rootCmd.PersistentFlags() + persistentFlags.Var(&c.CacheDirAbsPath, "cache", "Set cache directory") persistentFlags.Var(&c.Color, "color", "Colorize output") persistentFlags.VarP(&c.DestDirAbsPath, "destination", "D", "Set destination directory") persistentFlags.Var(&c.Mode, "mode", "Mode") - persistentFlags.Var(&c.persistentStateAbsPath, "persistent-state", "Set persistent state file") + persistentFlags.Var(&c.PersistentStateAbsPath, "persistent-state", "Set persistent state file") + persistentFlags.Var(&c.Progress, "progress", "Display progress bars") persistentFlags.BoolVar(&c.Safe, "safe", c.Safe, "Safely replace files and symlinks") persistentFlags.VarP(&c.SourceDirAbsPath, "source", "S", "Set source directory") persistentFlags.Var(&c.UseBuiltinAge, "use-builtin-age", "Use builtin age") persistentFlags.Var(&c.UseBuiltinGit, "use-builtin-git", "Use builtin git") persistentFlags.BoolVarP(&c.Verbose, "verbose", "v", c.Verbose, "Make output more verbose") persistentFlags.VarP(&c.WorkingTreeAbsPath, "working-tree", "W", "Set working tree directory") - for viperKey, key := range map[string]string{ - "color": "color", - "destDir": "destination", - "persistentState": "persistent-state", - "mode": "mode", - "safe": "safe", - "sourceDir": "source", - "useBuiltinAge": "use-builtin-age", - "useBuiltinGit": "use-builtin-git", - "verbose": "verbose", - "workingTree": "working-tree", - } { - if err := viper.BindPFlag(viperKey, persistentFlags.Lookup(key)); err != nil { - return nil, err - } - } - persistentFlags.VarP(&c.configFileAbsPath, "config", "c", "Set config file") + persistentFlags.VarP(&c.customConfigFileAbsPath, "config", "c", "Set config file") persistentFlags.Var(&c.configFormat, "config-format", "Set config file format") persistentFlags.Var(&c.cpuProfile, "cpu-profile", "Write a CPU profile to path") persistentFlags.BoolVar(&c.debug, "debug", c.debug, "Include debug information in output") - persistentFlags.BoolVarP( - &c.dryRun, "dry-run", "n", c.dryRun, "Do not make any modifications to the destination directory", - ) + persistentFlags.BoolVarP(&c.dryRun, "dry-run", "n", c.dryRun, "Do not make any modifications to the destination directory") persistentFlags.BoolVar(&c.force, "force", c.force, "Make all changes without prompting") - persistentFlags.BoolVar(&c.gops, "gops", c.gops, "Enable gops agent") + persistentFlags.BoolVar(&c.interactive, "interactive", c.interactive, "Prompt for all changes") persistentFlags.BoolVarP(&c.keepGoing, "keep-going", "k", c.keepGoing, "Keep going as far as possible after an error") persistentFlags.BoolVar(&c.noPager, "no-pager", c.noPager, "Do not use the pager") - persistentFlags.BoolVar(&c.noTTY, "no-tty", c.noTTY, "Do not attempt to get a TTY for reading passwords") + persistentFlags.BoolVar(&c.noTTY, "no-tty", c.noTTY, "Do not attempt to get a TTY for prompts") persistentFlags.VarP(&c.outputAbsPath, "output", "o", "Write output to path instead of stdout") - persistentFlags.BoolVarP(&c.refreshExternals, "refresh-externals", "R", c.refreshExternals, "Refresh external cache") + persistentFlags.VarP(&c.refreshExternals, "refresh-externals", "R", "Refresh external cache") + persistentFlags.Lookup("refresh-externals").NoOptDefVal = chezmoi.RefreshExternalsAlways.String() persistentFlags.BoolVar(&c.sourcePath, "source-path", c.sourcePath, "Specify targets by source path") + persistentFlags.BoolVarP(&c.useBuiltinDiff, "use-builtin-diff", "", c.useBuiltinDiff, "Use builtin diff") - for _, err := range []error{ + if err := chezmoierrors.Combine( rootCmd.MarkPersistentFlagFilename("config"), rootCmd.MarkPersistentFlagFilename("cpu-profile"), persistentFlags.MarkHidden("cpu-profile"), rootCmd.MarkPersistentFlagDirname("destination"), - persistentFlags.MarkHidden("gops"), rootCmd.MarkPersistentFlagFilename("output"), persistentFlags.MarkHidden("safe"), rootCmd.MarkPersistentFlagDirname("source"), - } { - if err != nil { - return nil, err - } + rootCmd.RegisterFlagCompletionFunc("color", autoBoolFlagCompletionFunc), + rootCmd.RegisterFlagCompletionFunc("config-format", readDataFormatFlagCompletionFunc), + rootCmd.RegisterFlagCompletionFunc("mode", chezmoi.ModeFlagCompletionFunc), + rootCmd.RegisterFlagCompletionFunc("refresh-externals", chezmoi.RefreshExternalsFlagCompletionFunc), + rootCmd.RegisterFlagCompletionFunc("use-builtin-age", autoBoolFlagCompletionFunc), + rootCmd.RegisterFlagCompletionFunc("use-builtin-git", autoBoolFlagCompletionFunc), + ); err != nil { + return nil, err } rootCmd.SetHelpCommand(c.newHelpCmd()) for _, cmd := range []*cobra.Command{ c.newAddCmd(), + c.newAgeCmd(), c.newApplyCmd(), c.newArchiveCmd(), c.newCatCmd(), + c.newCatConfigCmd(), c.newCDCmd(), c.newChattrCmd(), c.newCompletionCmd(), c.newDataCmd(), c.newDecryptCommand(), + c.newDestroyCmd(), c.newDiffCmd(), - c.newDocsCmd(), c.newDoctorCmd(), c.newDumpCmd(), + c.newDumpConfigCmd(), c.newEditCmd(), c.newEditConfigCmd(), + c.newEditConfigTemplateCmd(), c.newEncryptCommand(), c.newExecuteTemplateCmd(), c.newForgetCmd(), + c.newGenerateCmd(), c.newGitCmd(), + c.newIgnoredCmd(), c.newImportCmd(), c.newInitCmd(), c.newInternalTestCmd(), + c.newLicenseCmd(), + c.newMackupCmd(), c.newManagedCmd(), c.newMergeCmd(), c.newMergeAllCmd(), @@ -1332,12 +1684,14 @@ func (c *Config) newRootCmd() (*cobra.Command, error) { c.newSourcePathCmd(), c.newStateCmd(), c.newStatusCmd(), + c.newTargetPathCmd(), c.newUnmanagedCmd(), c.newUpdateCmd(), c.newUpgradeCmd(), c.newVerifyCmd(), } { if cmd != nil { + registerCommonFlagCompletionFuncs(cmd) rootCmd.AddCommand(cmd) } } @@ -1345,74 +1699,139 @@ func (c *Config) newRootCmd() (*cobra.Command, error) { return rootCmd, nil } +// newDiffSystem returns a system that logs all changes to s to w using +// diff.command if set or the builtin git diff otherwise. +func (c *Config) newDiffSystem(s chezmoi.System, w io.Writer, dirAbsPath chezmoi.AbsPath) chezmoi.System { + if c.useBuiltinDiff || c.Diff.Command == "" { + options := &chezmoi.GitDiffSystemOptions{ + Color: c.Color.Value(c.colorAutoFunc), + Filter: chezmoi.NewEntryTypeFilter(c.Diff.include.Bits(), c.Diff.Exclude.Bits()), + Reverse: c.Diff.Reverse, + ScriptContents: c.Diff.ScriptContents, + TextConvFunc: c.TextConv.convert, + } + return chezmoi.NewGitDiffSystem(s, w, dirAbsPath, options) + } + options := &chezmoi.ExternalDiffSystemOptions{ + Filter: chezmoi.NewEntryTypeFilter(c.Diff.include.Bits(), c.Diff.Exclude.Bits()), + Reverse: c.Diff.Reverse, + ScriptContents: c.Diff.ScriptContents, + } + return chezmoi.NewExternalDiffSystem(s, c.Diff.Command, c.Diff.Args, c.DestDirAbsPath, options) +} + // newSourceState returns a new SourceState with options. func (c *Config) newSourceState( - ctx context.Context, options ...chezmoi.SourceStateOption, + ctx context.Context, + cmd *cobra.Command, + options ...chezmoi.SourceStateOption, ) (*chezmoi.SourceState, error) { + if err := c.checkVersion(); err != nil { + return nil, err + } + httpClient, err := c.getHTTPClient() if err != nil { return nil, err } - sourceStateLogger := c.logger.With().Str(logComponentKey, logComponentValueSourceState).Logger() + sourceStateLogger := c.logger.With(logComponentKey, logComponentValueSourceState) - sourceDirAbsPath, err := c.sourceDirAbsPath() + c.SourceDirAbsPath, err = c.getSourceDirAbsPath(nil) if err != nil { return nil, err } - s := chezmoi.NewSourceState(append([]chezmoi.SourceStateOption{ + if err := c.runHookPre(readSourceStateHookName); err != nil { + return nil, err + } + + sourceState := chezmoi.NewSourceState(append([]chezmoi.SourceStateOption{ chezmoi.WithBaseSystem(c.baseSystem), chezmoi.WithCacheDir(c.CacheDirAbsPath), - chezmoi.WithDefaultTemplateDataFunc(c.defaultTemplateData), + chezmoi.WithDefaultTemplateDataFunc(func() map[string]any { + return c.getTemplateDataMap(cmd) + }), chezmoi.WithDestDir(c.DestDirAbsPath), chezmoi.WithEncryption(c.encryption), chezmoi.WithHTTPClient(httpClient), chezmoi.WithInterpreters(c.Interpreters), - chezmoi.WithLogger(&sourceStateLogger), + chezmoi.WithLogger(sourceStateLogger), chezmoi.WithMode(c.Mode), chezmoi.WithPriorityTemplateData(c.Data), - chezmoi.WithSourceDir(sourceDirAbsPath), + chezmoi.WithScriptTempDir(c.ScriptTempDir), + chezmoi.WithSourceDir(c.SourceDirAbsPath), chezmoi.WithSystem(c.sourceSystem), chezmoi.WithTemplateFuncs(c.templateFuncs), chezmoi.WithTemplateOptions(c.Template.Options), + chezmoi.WithUmask(c.Umask), + chezmoi.WithVersion(c.version), }, options...)...) - if err := s.Read(ctx, &chezmoi.ReadOptions{ + if err := sourceState.Read(ctx, &chezmoi.ReadOptions{ RefreshExternals: c.refreshExternals, + ReadHTTPResponse: c.readHTTPResponse, }); err != nil { return nil, err } - if minVersion := s.MinVersion(); c.version != nil && !isDevVersion(c.version) && c.version.LessThan(minVersion) { - return nil, fmt.Errorf("source state requires version %s or later, chezmoi is version %s", minVersion, c.version) + if err := c.runHookPost(readSourceStateHookName); err != nil { + return nil, err } - return s, nil + return sourceState, nil } // persistentPostRunRootE performs post-run actions for the root command. func (c *Config) persistentPostRunRootE(cmd *cobra.Command, args []string) error { + annotations := getAnnotations(cmd) + 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(afero.FromIOFS{FS: c.fileSystem}) - v.SetConfigFile(c.configFileAbsPath.String()) - err := v.ReadInConfig() - if err == nil { - err = v.Unmarshal(&Config{}, viperDecodeConfigOptions...) + // Close any connection to keepassxc-cli. + if err := c.keepassxcClose(); err != nil { + return err + } + + // Wait for any diff pager process to terminate. + if c.diffPagerCmd != nil { + if err := c.diffPagerCmdStdin.Close(); err != nil { + return err + } + if c.diffPagerCmd.Process != nil { + if err := chezmoilog.LogCmdWait(c.logger, c.diffPagerCmd); err != nil { + return err + } + } + } + + if annotations.hasTag(modifiesConfigFile) { + configFileContents, err := c.baseSystem.ReadFile(c.getConfigFileAbsPath()) + switch { + case errors.Is(err, fs.ErrNotExist): + err = nil + case err != nil: + // err is already set, do nothing. + default: + var format chezmoi.Format + if format, err = chezmoi.FormatFromAbsPath(c.getConfigFileAbsPath()); err == nil { + var config map[string]any + if err = format.Unmarshal(configFileContents, &config); err != nil { //nolint:revive + // err is already set, do nothing. + } else { + err = c.decodeConfigMap(config, &ConfigFile{}) + } + } } if err != nil { - c.errorf("warning: %s: %v\n", c.configFileAbsPath, err) + c.errorf("warning: %s: %v\n", c.getConfigFileAbsPath(), err) } } - if boolAnnotation(cmd, modifiesSourceDirectory) { - var status *git.Status + if annotations.hasTag(modifiesSourceDirectory) { + var status *chezmoigit.Status if c.Git.AutoAdd || c.Git.AutoCommit || c.Git.AutoPush { var err error status, err = c.gitAutoAdd() @@ -1421,7 +1840,7 @@ func (c *Config) persistentPostRunRootE(cmd *cobra.Command, args []string) error } } if c.Git.AutoCommit || c.Git.AutoPush { - if err := c.gitAutoCommit(status); err != nil { + if err := c.gitAutoCommit(cmd, status); err != nil { return err } } @@ -1432,34 +1851,46 @@ func (c *Config) persistentPostRunRootE(cmd *cobra.Command, args []string) error } } + if c.restoreWindowsConsole != nil { + if err := c.restoreWindowsConsole(); err != nil { + return err + } + } + + if err := c.runHookPost(cmd.Name()); err != nil { + return err + } + return nil } -// pageOutputString writes output using cmdPager as the pager command. -func (c *Config) pageOutputString(output, cmdPager string) error { - pager := firstNonEmptyString(cmdPager, c.Pager) - if c.noPager || pager == "" { +// pageDiffOutput pages the diff output to stdout. +func (c *Config) pageDiffOutput(output string) error { + switch pagerCmd, err := c.getDiffPagerCmd(); { + case err != nil: + return err + case pagerCmd == nil: return c.writeOutputString(output) + default: + pagerCmd.Stdin = bytes.NewBufferString(output) + return chezmoilog.LogCmdRun(c.logger, pagerCmd) } - - // If the pager command contains any spaces, assume that it is a full - // shell command to be executed via the user's shell. Otherwise, execute - // it directly. - var pagerCmd *exec.Cmd - if strings.IndexFunc(pager, unicode.IsSpace) != -1 { - shell, _ := shell.CurrentUserShell() - pagerCmd = exec.Command(shell, "-c", pager) - } else { - pagerCmd = exec.Command(pager) - } - pagerCmd.Stdin = bytes.NewBufferString(output) - pagerCmd.Stdout = c.stdout - pagerCmd.Stderr = c.stderr - return pagerCmd.Run() } // persistentPreRunRootE performs pre-run actions for the root command. func (c *Config) persistentPreRunRootE(cmd *cobra.Command, args []string) error { + annotations := getAnnotations(cmd) + + // Add the completion template function. This needs cmd, so we can't do it + // in newConfig. + c.addTemplateFunc("completion", func(shell string) string { + completion, err := completion(cmd, shell) + if err != nil { + panic(err) + } + return completion + }) + // Enable CPU profiling if configured. if !c.cpuProfile.Empty() { f, err := os.Create(c.cpuProfile.String()) @@ -1471,102 +1902,128 @@ func (c *Config) persistentPreRunRootE(cmd *cobra.Command, args []string) error } } - // Enable gops if configured. - if c.gops { - if err := agent.Listen(agent.Options{}); err != nil { + if runtime.GOOS == "windows" { + var err error + c.restoreWindowsConsole, err = termenv.EnableVirtualTerminalProcessing(termenv.DefaultOutput()) + if err != nil { return err } } + // Save flags that were set on the command line. Skip some types as + // spf13/pflag does not round trip them correctly. + changedFlags := make(map[pflag.Value]string) + brokenFlagTypes := map[string]bool{ + "stringToInt": true, + "stringToInt64": true, + "stringToString": true, + } + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + if flag.Changed && !brokenFlagTypes[flag.Value.Type()] { + changedFlags[flag.Value] = flag.Value.String() + } + }) + // Read the config file. - if err := c.readConfig(); err != nil { - if !boolAnnotation(cmd, doesNotRequireValidConfig) { - return fmt.Errorf("invalid config: %s: %w", c.configFileAbsPath, err) + if annotations.hasTag(doesNotRequireValidConfig) { + if c.defaultConfigFileAbsPathErr == nil { + _ = c.readConfig() + } + } else { + if c.defaultConfigFileAbsPathErr != nil { + return c.defaultConfigFileAbsPathErr + } + if err := c.readConfig(); err != nil { + return fmt.Errorf("invalid config: %s: %w", c.getConfigFileAbsPath(), err) } - c.errorf("warning: %s: %v\n", c.configFileAbsPath, err) } - // Determine whether color should be used. - color := c.Color.Value(c.colorAutoFunc) - if color { - if err := enableVirtualTerminalProcessing(c.stdout); err != nil { + // Restore flags that were set on the command line. + for value, original := range changedFlags { + if err := value.Set(original); err != nil { return err } } + if c.force && c.interactive { + return errors.New("the --force and --interactive flags are mutually exclusive") + } + // Configure the logger. - log.Logger = log.Output(zerolog.NewConsoleWriter( - func(w *zerolog.ConsoleWriter) { - w.Out = c.stderr - w.NoColor = !color - w.TimeFormat = time.RFC3339 - }, - )) + var handler slog.Handler if c.debug { - zerolog.SetGlobalLevel(zerolog.InfoLevel) + handler = slog.NewTextHandler(c.stderr, nil) } else { - zerolog.SetGlobalLevel(zerolog.Disabled) + handler = chezmoilog.NullHandler{} } - c.logger = &log.Logger + c.logger = slog.New(handler) + slog.SetDefault(c.logger) // Log basic information. - c.logger.Info(). - Object("version", c.versionInfo). - Strs("args", args). - Str("goVersion", runtime.Version()). - Msg("persistentPreRunRootE") - - c.baseSystem = chezmoi.NewRealSystem(c.fileSystem, + c.logger.Info("persistentPreRunRootE", + slog.Any("version", c.versionInfo), + slog.Any("args", os.Args), + slog.String("goVersion", runtime.Version()), + ) + realSystem := chezmoi.NewRealSystem(c.fileSystem, chezmoi.RealSystemWithSafe(c.Safe), + chezmoi.RealSystemWithScriptTempDir(c.ScriptTempDir), ) + c.baseSystem = realSystem if c.debug { - systemLogger := c.logger.With().Str(logComponentKey, logComponentValueSystem).Logger() - c.baseSystem = chezmoi.NewDebugSystem(c.baseSystem, &systemLogger) + systemLogger := c.logger.With(slog.String(logComponentKey, logComponentValueSystem)) + c.baseSystem = chezmoi.NewDebugSystem(c.baseSystem, systemLogger) } // Set up the persistent state. - switch { - case cmd.Annotations[persistentStateMode] == persistentStateModeEmpty: + switch persistentStateMode := annotations.persistentStateMode(); { + case persistentStateMode == persistentStateModeEmpty: c.persistentState = chezmoi.NewMockPersistentState() - case cmd.Annotations[persistentStateMode] == persistentStateModeReadOnly: + case persistentStateMode == persistentStateModeReadOnly: persistentStateFileAbsPath, err := c.persistentStateFile() if err != nil { return err } c.persistentState, err = chezmoi.NewBoltPersistentState( - c.baseSystem, persistentStateFileAbsPath, chezmoi.BoltPersistentStateReadOnly, + c.baseSystem, + persistentStateFileAbsPath, + chezmoi.BoltPersistentStateReadOnly, ) if err != nil { return err } - case cmd.Annotations[persistentStateMode] == persistentStateModeReadMockWrite: + case persistentStateMode == persistentStateModeReadMockWrite: fallthrough - case cmd.Annotations[persistentStateMode] == persistentStateModeReadWrite && c.dryRun: + case persistentStateMode == persistentStateModeReadWrite && c.dryRun: persistentStateFileAbsPath, err := c.persistentStateFile() if err != nil { return err } persistentState, err := chezmoi.NewBoltPersistentState( - c.baseSystem, persistentStateFileAbsPath, chezmoi.BoltPersistentStateReadOnly, + c.baseSystem, + persistentStateFileAbsPath, + chezmoi.BoltPersistentStateReadOnly, ) if err != nil { return err } - dryRunPeristentState := chezmoi.NewMockPersistentState() - if err := persistentState.CopyTo(dryRunPeristentState); err != nil { + dryRunPersistentState := chezmoi.NewMockPersistentState() + if err := persistentState.CopyTo(dryRunPersistentState); err != nil { return err } if err := persistentState.Close(); err != nil { return err } - c.persistentState = dryRunPeristentState - case cmd.Annotations[persistentStateMode] == persistentStateModeReadWrite: + c.persistentState = dryRunPersistentState + case persistentStateMode == persistentStateModeReadWrite: persistentStateFileAbsPath, err := c.persistentStateFile() if err != nil { return err } c.persistentState, err = chezmoi.NewBoltPersistentState( - c.baseSystem, persistentStateFileAbsPath, chezmoi.BoltPersistentStateReadWrite, + c.baseSystem, + persistentStateFileAbsPath, + chezmoi.BoltPersistentStateReadWrite, ) if err != nil { return err @@ -1575,81 +2032,82 @@ func (c *Config) persistentPreRunRootE(cmd *cobra.Command, args []string) error c.persistentState = chezmoi.NullPersistentState{} } if c.debug && c.persistentState != nil { - persistentStateLogger := c.logger.With().Str(logComponentKey, logComponentValuePersistentState).Logger() - c.persistentState = chezmoi.NewDebugPersistentState(c.persistentState, &persistentStateLogger) + persistentStateLogger := c.logger.With(slog.String(logComponentKey, logComponentValuePersistentState)) + c.persistentState = chezmoi.NewDebugPersistentState(c.persistentState, persistentStateLogger) } // Set up the source and destination systems. c.sourceSystem = c.baseSystem c.destSystem = c.baseSystem - if !boolAnnotation(cmd, modifiesDestinationDirectory) { + if !annotations.hasTag(modifiesDestinationDirectory) { c.destSystem = chezmoi.NewReadOnlySystem(c.destSystem) } - if !boolAnnotation(cmd, modifiesSourceDirectory) { + if !annotations.hasTag(modifiesSourceDirectory) { c.sourceSystem = chezmoi.NewReadOnlySystem(c.sourceSystem) } - if c.dryRun { + if c.dryRun || annotations.hasTag(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, &chezmoi.GitDiffSystemOptions{ - Color: color, - Include: c.Diff.include.Sub(c.Diff.Exclude), - }) - c.destSystem = chezmoi.NewGitDiffSystem(c.destSystem, c.stdout, c.DestDirAbsPath, &chezmoi.GitDiffSystemOptions{ - Color: color, - Include: c.Diff.include.Sub(c.Diff.Exclude), - }) - } - - // Set up encryption. - switch c.Encryption { - case "age": - // Only use builtin age encryption if age encryption is explicitly - // specified. Otherwise, chezmoi would fall back to using age encryption - // (rather than no encryption) if age is not in $PATH, which leads to - // error messages from the builtin age instead of error messages about - // encryption not being configured. - c.Age.UseBuiltin = c.UseBuiltinAge.Value(c.useBuiltinAgeAutoFunc) - c.encryption = &c.Age - case "gpg": - c.encryption = &c.GPG - case "": - // Detect encryption if any non-default configuration is set, preferring - // gpg for backwards compatibility. - switch { - case !reflect.DeepEqual(c.GPG, defaultGPGEncryptionConfig): - c.encryption = &c.GPG - case !reflect.DeepEqual(c.Age, defaultAgeEncryptionConfig): - c.encryption = &c.Age + if annotations.hasTag(outputsDiff) || + c.Verbose && (annotations.hasTag(modifiesDestinationDirectory) || annotations.hasTag(modifiesSourceDirectory)) { + // If the user has configured a diff pager, then start it as a process. + // Otherwise, write the diff output directly to stdout. + var writer io.Writer + switch pagerCmd, err := c.getDiffPagerCmd(); { + case err != nil: + return err + case pagerCmd == nil: + writer = c.stdout default: - c.encryption = chezmoi.NoEncryption{} + pipeReader, pipeWriter := io.Pipe() + pagerCmd.Stdin = pipeReader + lazyWriter := newLazyWriter(func() (io.WriteCloser, error) { + if err := chezmoilog.LogCmdStart(c.logger, pagerCmd); err != nil { + return nil, err + } + return pipeWriter, nil + }) + writer = lazyWriter + c.diffPagerCmd = pagerCmd + c.diffPagerCmdStdin = lazyWriter } - default: - return fmt.Errorf("%s: unknown encryption", c.Encryption) + c.sourceSystem = c.newDiffSystem(c.sourceSystem, writer, c.SourceDirAbsPath) + c.destSystem = c.newDiffSystem(c.destSystem, writer, c.DestDirAbsPath) } - if c.debug { - encryptionLogger := c.logger.With().Str(logComponentKey, logComponentValueEncryption).Logger() - c.encryption = chezmoi.NewDebugEncryption(c.encryption, &encryptionLogger) + + if err := c.setEncryption(); err != nil { + return err } // Create the config directory if needed. - if boolAnnotation(cmd, requiresConfigDirectory) { - if err := chezmoi.MkdirAll(c.baseSystem, c.configFileAbsPath.Dir(), 0o777); err != nil { + if annotations.hasTag(requiresConfigDirectory) { + if err := chezmoi.MkdirAll(c.baseSystem, c.getConfigFileAbsPath().Dir(), fs.ModePerm); err != nil { return err } } // Create the source directory if needed. - if boolAnnotation(cmd, requiresSourceDirectory) { - if err := chezmoi.MkdirAll(c.baseSystem, c.SourceDirAbsPath, 0o777); err != nil { + if annotations.hasTag(createSourceDirectoryIfNeeded) { + if err := chezmoi.MkdirAll(c.baseSystem, c.SourceDirAbsPath, fs.ModePerm); err != nil { + return err + } + } + + // Verify that the source directory exists and is a directory, if needed. + if annotations.hasTag(requiresSourceDirectory) { + switch fileInfo, err := c.baseSystem.Stat(c.SourceDirAbsPath); { + case err == nil && fileInfo.IsDir(): + // Do nothing. + case err == nil: + return fmt.Errorf("%s: not a directory", c.SourceDirAbsPath) + default: return err } } // Create the runtime directory if needed. - if boolAnnotation(cmd, runsCommands) { + if annotations.hasTag(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 @@ -1668,8 +2126,8 @@ func (c *Config) persistentPreRunRootE(cmd *cobra.Command, args []string) error workingTreeAbsPath := c.SourceDirAbsPath FOR: for { - gitDirAbsPath := workingTreeAbsPath.JoinString(gogit.GitDirName) - if fileInfo, err := c.baseSystem.Stat(gitDirAbsPath); err == nil && fileInfo.IsDir() { + gitDirAbsPath := workingTreeAbsPath.JoinString(git.GitDirName) + if _, err := c.baseSystem.Stat(gitDirAbsPath); err == nil { c.WorkingTreeAbsPath = workingTreeAbsPath break FOR } @@ -1683,15 +2141,62 @@ func (c *Config) persistentPreRunRootE(cmd *cobra.Command, args []string) error } // Create the working tree directory if needed. - if boolAnnotation(cmd, requiresWorkingTree) { + if annotations.hasTag(requiresWorkingTree) { if _, err := c.SourceDirAbsPath.TrimDirPrefix(c.WorkingTreeAbsPath); err != nil { return err } - if err := chezmoi.MkdirAll(c.baseSystem, c.WorkingTreeAbsPath, 0o777); err != nil { + if err := chezmoi.MkdirAll(c.baseSystem, c.WorkingTreeAbsPath, fs.ModePerm); err != nil { return err } } + templateData := c.getTemplateData(cmd) + os.Setenv("CHEZMOI", "1") + for key, value := range map[string]string{ + "ARCH": templateData.arch, + "ARGS": strings.Join(templateData.args, " "), + "CACHE_DIR": templateData.cacheDir.String(), + "COMMAND": templateData.command, + "COMMAND_DIR": templateData.commandDir.String(), + "CONFIG_FILE": templateData.configFile.String(), + "EXECUTABLE": templateData.executable.String(), + "FQDN_HOSTNAME": templateData.fqdnHostname, + "GID": templateData.gid, + "GROUP": templateData.group, + "HOME_DIR": templateData.homeDir.String(), + "HOSTNAME": templateData.hostname, + "OS": templateData.os, + "SOURCE_DIR": templateData.sourceDir.String(), + "UID": templateData.uid, + "USERNAME": templateData.username, + "WORKING_TREE": templateData.workingTree.String(), + } { + os.Setenv("CHEZMOI_"+key, value) + } + if c.Verbose { + os.Setenv("CHEZMOI_VERBOSE", "1") + } + for groupKey, group := range map[string]map[string]any{ + "KERNEL": templateData.kernel, + "OS_RELEASE": templateData.osRelease, + "VERSION": templateData.version, + "WINDOWS_VERSION": templateData.windowsVersion, + } { + for key, value := range group { + key := "CHEZMOI_" + groupKey + "_" + camelCaseToUpperSnakeCase(key) + valueStr := fmt.Sprintf("%s", value) + os.Setenv(key, valueStr) + } + } + + if err := c.setEnvironmentVariables(); err != nil { + return err + } + + if err := c.runHookPre(cmd.Name()); err != nil { + return err + } + return nil } @@ -1699,11 +2204,11 @@ func (c *Config) persistentPreRunRootE(cmd *cobra.Command, args []string) error // returning the first persistent file found, and returning the default path if // none are found. func (c *Config) persistentStateFile() (chezmoi.AbsPath, error) { - if !c.persistentStateAbsPath.Empty() { - return c.persistentStateAbsPath, nil + if !c.PersistentStateAbsPath.Empty() { + return c.PersistentStateAbsPath, nil } - if !c.configFileAbsPath.Empty() { - return c.configFileAbsPath.Dir().Join(persistentStateFileRelPath), nil + if !c.getConfigFileAbsPath().Empty() { + return c.getConfigFileAbsPath().Dir().Join(persistentStateFileRelPath), nil } for _, configDir := range c.bds.ConfigDirs { configDirAbsPath, err := chezmoi.NewAbsPathFromExtPath(configDir, c.homeDirAbsPath) @@ -1722,51 +2227,143 @@ func (c *Config) persistentStateFile() (chezmoi.AbsPath, error) { return defaultConfigFileAbsPath.Dir().Join(persistentStateFileRelPath), nil } -// promptChoice prompts the user for one of choices until a valid choice is made. -func (c *Config) promptChoice(prompt string, choices []string) (string, error) { - promptWithChoices := fmt.Sprintf("%s [%s]? ", prompt, strings.Join(choices, ",")) - abbreviations := uniqueAbbreviations(choices) - for { - line, err := c.readLine(promptWithChoices) - if err != nil { - return "", err +// progressAutoFunc detects whether progress bars should be displayed. +func (c *Config) progressAutoFunc() bool { + if stdout, ok := c.stdout.(*os.File); ok { + return term.IsTerminal(int(stdout.Fd())) + } + return false +} + +func (c *Config) newTemplateData(cmd *cobra.Command) *templateData { + // Determine the user's username and group, if possible. + // + // os/user.Current and os/user.LookupGroupId in Go's standard library are + // generally unreliable, so work around errors if possible, or ignore them. + // + // On Android, user.Current always fails. Instead, use $LOGNAME (as this is + // set by Termux), or $USER if $LOGNAME is not set. + // + // 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 os/user.Current and os/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. + // + // On Windows, the user's group ID returned by os/user.Current() is an SID + // and no further useful lookup is possible with Go's standard library. + // + // If os/user.Current fails, then fallback to $USER. + // + // 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. + var gid, group, uid, username string + if runtime.GOOS == "android" { + username = firstNonEmptyString(os.Getenv("LOGNAME"), os.Getenv("USER")) + } else if currentUser, err := user.Current(); err == nil { + gid = currentUser.Gid + uid = currentUser.Uid + username = currentUser.Username + if runtime.GOOS != "windows" { + if rawGroup, err := user.LookupGroupId(currentUser.Gid); err == nil { + group = rawGroup.Name + } else { + c.logger.Info("user.LookupGroupId", slog.Any("err", err), slog.String("gid", currentUser.Gid)) + } + } + } else { + c.logger.Error("user.Current", slog.Any("err", err)) + var ok bool + username, ok = os.LookupEnv("USER") + if !ok { + c.logger.Info("os.LookupEnv", slog.String("key", "USER"), slog.Bool("ok", ok)) } - if value, ok := abbreviations[strings.TrimSpace(line)]; ok { - return value, nil + } + + fqdnHostname, err := chezmoi.FQDNHostname(c.fileSystem) + if err != nil { + c.logger.Info("chezmoi.FQDNHostname", slog.Any("err", err)) + } + hostname, _, _ := strings.Cut(fqdnHostname, ".") + + kernel, err := chezmoi.Kernel(c.fileSystem) + if err != nil { + c.logger.Info("chezmoi.Kernel", slog.Any("err", err)) + } + + var osRelease map[string]any + switch runtime.GOOS { + case "openbsd", "windows": + // Don't populate osRelease on OSes where /etc/os-release does not + // exist. + default: + if rawOSRelease, err := chezmoi.OSRelease(c.fileSystem); err == nil { + osRelease = upperSnakeCaseToCamelCaseMap(rawOSRelease) + } else { + c.logger.Info("chezmoi.OSRelease", slog.Any("err", err)) } } + + executable, _ := os.Executable() + windowsVersion, _ := windowsVersion() + sourceDirAbsPath, _ := c.getSourceDirAbsPath(nil) + + return &templateData{ + arch: runtime.GOARCH, + args: os.Args, + cacheDir: c.CacheDirAbsPath, + command: cmd.Name(), + commandDir: c.commandDirAbsPath, + config: c.ConfigFile.toMap(), + configFile: c.getConfigFileAbsPath(), + executable: chezmoi.NewAbsPath(executable), + fqdnHostname: fqdnHostname, + gid: gid, + group: group, + homeDir: c.homeDirAbsPath, + hostname: hostname, + kernel: kernel, + os: runtime.GOOS, + osRelease: osRelease, + pathListSeparator: string(os.PathListSeparator), + pathSeparator: string(os.PathSeparator), + sourceDir: sourceDirAbsPath, + uid: uid, + username: username, + version: map[string]any{ + "builtBy": c.versionInfo.BuiltBy, + "commit": c.versionInfo.Commit, + "date": c.versionInfo.Date, + "version": c.versionInfo.Version, + }, + windowsVersion: windowsVersion, + workingTree: c.WorkingTreeAbsPath, + } } // readConfig reads the config file, if it exists. func (c *Config) readConfig() error { - viper.SetConfigFile(c.configFileAbsPath.String()) - if c.configFormat != "" { - viper.SetConfigType(c.configFormat.String()) - } - viper.SetFs(afero.FromIOFS{FS: c.fileSystem}) - switch err := viper.ReadInConfig(); { + switch err := c.decodeConfigFile(c.getConfigFileAbsPath(), &c.ConfigFile); { case errors.Is(err, fs.ErrNotExist): return nil - case err != nil: - return err - } - if err := viper.Unmarshal(c, viperDecodeConfigOptions...); err != nil { + default: return err } - return c.validateData() } -// readLine reads a line from stdin, trimming leading and trailing whitespace. -func (c *Config) readLine(prompt string) (string, error) { - _, err := c.stdout.Write([]byte(prompt)) - if err != nil { - return "", err - } - line, err := bufio.NewReader(c.stdin).ReadString('\n') - if err != nil { - return "", err - } - return strings.TrimSpace(line), nil +// resetSourceState clears the cached source state, if any. +func (c *Config) resetSourceState() { + c.sourceState = nil + c.sourceStateErr = nil } // run runs name with args in dir. @@ -1782,7 +2379,10 @@ func (c *Config) run(dir chezmoi.AbsPath, name string, args []string) error { cmd.Stdin = c.stdin cmd.Stdout = c.stdout cmd.Stderr = c.stderr - return c.baseSystem.RunCmd(cmd) + if err := chezmoilog.LogCmdRun(c.logger, cmd); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil } // runEditor runs the configured editor with args. @@ -1790,9 +2390,12 @@ func (c *Config) runEditor(args []string) error { if err := c.persistentState.Close(); err != nil { return err } - editor, editorArgs := c.editor(args) + editor, editorArgs, err := c.editor(args) + if err != nil { + return err + } start := time.Now() - err := c.run(chezmoi.EmptyAbsPath, editor, editorArgs) + err = c.run(chezmoi.EmptyAbsPath, editor, editorArgs) if runtime.GOOS != "windows" && c.Edit.MinDuration != 0 { if duration := time.Since(start); duration < c.Edit.MinDuration { c.errorf("warning: %s: returned in less than %s\n", shellQuoteCommand(editor, editorArgs), c.Edit.MinDuration) @@ -1801,10 +2404,86 @@ func (c *Config) runEditor(args []string) error { return err } +// runHookPost runs the hook's post command, if it is set. +func (c *Config) runHookPost(hook string) error { + command := c.Hooks[hook].Post + if command.Command == "" { + return nil + } + return c.run(c.homeDirAbsPath, command.Command, command.Args) +} + +// runHookPre runs the hook's pre command, if it is set. +func (c *Config) runHookPre(hook string) error { + command := c.Hooks[hook].Pre + if command.Command == "" { + return nil + } + return c.run(c.homeDirAbsPath, command.Command, command.Args) +} + +// setEncryption configures c's encryption. +func (c *Config) setEncryption() error { + switch c.Encryption { + case "age": + // Only use builtin age encryption if age encryption is explicitly + // specified. Otherwise, chezmoi would fall back to using age encryption + // (rather than no encryption) if age is not in $PATH, which leads to + // error messages from the builtin age instead of error messages about + // encryption not being configured. + c.Age.UseBuiltin = c.UseBuiltinAge.Value(c.useBuiltinAgeAutoFunc) + c.encryption = &c.Age + case "gpg": + c.encryption = &c.GPG + case "": + // Detect encryption if any non-default configuration is set, preferring + // gpg for backwards compatibility. + switch { + case !reflect.DeepEqual(c.GPG, defaultGPGEncryptionConfig): + c.encryption = &c.GPG + case !reflect.DeepEqual(c.Age, defaultAgeEncryptionConfig): + c.encryption = &c.Age + default: + c.encryption = chezmoi.NoEncryption{} + } + default: + return fmt.Errorf("%s: unknown encryption", c.Encryption) + } + + if c.debug { + encryptionLogger := c.logger.With(logComponentKey, logComponentValueEncryption) + c.encryption = chezmoi.NewDebugEncryption(c.encryption, encryptionLogger) + } + + return nil +} + +// setEnvironmentVariables sets all environment variables defined in c. +func (c *Config) setEnvironmentVariables() error { + var env map[string]string + switch { + case len(c.Env) != 0 && len(c.ScriptEnv) != 0: + return errors.New("only one of env or scriptEnv may be set") + case len(c.Env) != 0: + env = c.Env + case len(c.ScriptEnv) != 0: + env = c.ScriptEnv + } + for key, value := range env { + if strings.HasPrefix(key, "CHEZMOI_") { + c.errorf("warning: %s: overriding reserved environment variable", key) + } + if err := os.Setenv(key, value); err != nil { + return err + } + } + return nil +} + // sourceAbsPaths returns the source absolute paths for each target path in // args. func (c *Config) sourceAbsPaths(sourceState *chezmoi.SourceState, args []string) ([]chezmoi.AbsPath, error) { - targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ + targetRelPaths, err := c.targetRelPaths(sourceState, args, &targetRelPathsOptions{ mustBeInSourceState: true, }) if err != nil { @@ -1818,17 +2497,12 @@ func (c *Config) sourceAbsPaths(sourceState *chezmoi.SourceState, args []string) return sourceAbsPaths, nil } -// sourceDirAbsPath returns the source directory, using .chezmoiroot if it -// exists. -func (c *Config) sourceDirAbsPath() (chezmoi.AbsPath, error) { - switch data, err := c.sourceSystem.ReadFile(c.SourceDirAbsPath.JoinString(chezmoi.RootName)); { - case errors.Is(err, fs.ErrNotExist): - return c.SourceDirAbsPath, nil - case err != nil: - return chezmoi.EmptyAbsPath, err - default: - return c.SourceDirAbsPath.JoinString(string(bytes.TrimSpace(data))), nil +func (c *Config) targetRelPath(absPath chezmoi.AbsPath) (chezmoi.RelPath, error) { + relPath, err := absPath.TrimDirPrefix(c.DestDirAbsPath) + if notInAbsDirError := (&chezmoi.NotInAbsDirError{}); errors.As(err, ¬InAbsDirError) { + return chezmoi.EmptyRelPath, fmt.Errorf("%s: not in destination directory (%s)", absPath, c.DestDirAbsPath) } + return relPath, err } type targetRelPathsOptions struct { @@ -1839,7 +2513,9 @@ type targetRelPathsOptions struct { // targetRelPaths returns the target relative paths for each target path in // args. The returned paths are sorted and de-duplicated. func (c *Config) targetRelPaths( - sourceState *chezmoi.SourceState, args []string, options targetRelPathsOptions, + sourceState *chezmoi.SourceState, + args []string, + options *targetRelPathsOptions, ) (chezmoi.RelPaths, error) { targetRelPaths := make(chezmoi.RelPaths, 0, len(args)) for _, arg := range args { @@ -1847,20 +2523,21 @@ func (c *Config) targetRelPaths( if err != nil { return nil, err } - targetRelPath, err := argAbsPath.TrimDirPrefix(c.DestDirAbsPath) + targetRelPath, err := c.targetRelPath(argAbsPath) if err != nil { return nil, err } - if err != nil { - return nil, err + sourceStateEntry := sourceState.Get(targetRelPath) + if sourceStateEntry == nil { + return nil, fmt.Errorf("%s: not managed", arg) } - if options.mustBeInSourceState { - if !sourceState.Contains(targetRelPath) { + if options != nil && options.mustBeInSourceState { + if _, ok := sourceStateEntry.(*chezmoi.SourceStateRemove); ok { return nil, fmt.Errorf("%s: not in source state", arg) } } targetRelPaths = append(targetRelPaths, targetRelPath) - if options.recursive { + if options != nil && options.recursive { parentRelPath := targetRelPath // FIXME we should not call s.TargetRelPaths() here - risk of // accidentally quadratic @@ -1890,17 +2567,17 @@ func (c *Config) targetRelPaths( // targetRelPathsBySourcePath returns the target relative paths for each arg in // args. -func (c *Config) targetRelPathsBySourcePath( - sourceState *chezmoi.SourceState, args []string, -) ([]chezmoi.RelPath, error) { - targetRelPaths := make([]chezmoi.RelPath, 0, len(args)) +func (c *Config) targetRelPathsBySourcePath(sourceState *chezmoi.SourceState, args []string) ([]chezmoi.RelPath, error) { + targetRelPaths := make([]chezmoi.RelPath, len(args)) targetRelPathsBySourceRelPath := make(map[chezmoi.RelPath]chezmoi.RelPath) - _ = sourceState.ForEach(func(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi.SourceStateEntry) error { - sourceRelPath := sourceStateEntry.SourceRelPath().RelPath() - targetRelPathsBySourceRelPath[sourceRelPath] = targetRelPath - return nil - }) - for _, arg := range args { + _ = sourceState.ForEach( + func(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi.SourceStateEntry) error { + sourceRelPath := sourceStateEntry.SourceRelPath().RelPath() + targetRelPathsBySourceRelPath[sourceRelPath] = targetRelPath + return nil + }, + ) + for i, arg := range args { argAbsPath, err := chezmoi.NewAbsPathFromExtPath(arg, c.homeDirAbsPath) if err != nil { return nil, err @@ -1913,21 +2590,61 @@ func (c *Config) targetRelPathsBySourcePath( if !ok { return nil, fmt.Errorf("%s: not in source state", arg) } - targetRelPaths = append(targetRelPaths, targetRelPath) + targetRelPaths[i] = targetRelPath } return targetRelPaths, nil } +// targetValidArgs returns target completions for toComplete given args. +func (c *Config) targetValidArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if !c.Completion.Custom { + return nil, cobra.ShellCompDirectiveDefault + } + + toCompleteAbsPath, err := chezmoi.NewAbsPathFromExtPath(toComplete, c.homeDirAbsPath) + if err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveError + } + + sourceState, err := c.getSourceState(cmd.Context(), cmd) + if err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveError + } + + var completions []string + if err := sourceState.ForEach(func(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi.SourceStateEntry) error { + completion := c.DestDirAbsPath.Join(targetRelPath).String() + if _, ok := sourceStateEntry.(*chezmoi.SourceStateDir); ok { + completion += "/" + } + if strings.HasPrefix(completion, toCompleteAbsPath.String()) { + completions = append(completions, completion) + } + return nil + }); err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveError + } + + if !filepath.IsAbs(toComplete) { + for i, completion := range completions { + completions[i] = strings.TrimPrefix(completion, c.commandDirAbsPath.String()+"/") + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp +} + // tempDir returns the temporary directory for the given key, creating it if // needed. func (c *Config) tempDir(key string) (chezmoi.AbsPath, error) { if tempDirAbsPath, ok := c.tempDirs[key]; ok { return tempDirAbsPath, nil } - tempDir, err := os.MkdirTemp("", key) - c.logger.Err(err). - Str("tempDir", tempDir). - Msg("MkdirTemp") + tempDir, err := os.MkdirTemp(c.TempDir.String(), key) + chezmoilog.InfoOrError(c.logger, "MkdirTemp", err, slog.String("tempDir", tempDir)) if err != nil { return chezmoi.EmptyAbsPath, err } @@ -1943,37 +2660,44 @@ func (c *Config) tempDir(key string) (chezmoi.AbsPath, error) { // useBuiltinAgeAutoFunc detects whether the builtin age should be used. func (c *Config) useBuiltinAgeAutoFunc() bool { - if _, err := exec.LookPath(c.Age.Command); err == nil { + if _, err := chezmoi.LookPath(c.Age.Command); err == nil { return false } return true } -// useBuiltinGitAutoFunc detects whether the builitin git should be used. +// useBuiltinGitAutoFunc detects whether the builtin git should be used. func (c *Config) useBuiltinGitAutoFunc() bool { - // useBuiltinGit is false by default on Solaris as it uses the unavailable - // flock function. - if runtime.GOOS == "solaris" { - return false - } - if _, err := exec.LookPath(c.Git.Command); err == nil { + if _, err := chezmoi.LookPath(c.Git.Command); err == nil { return false } return true } -// validateData valides that the config data does not contain any invalid keys. -func (c *Config) validateData() error { - return validateKeys(c.Data, identifierRx) -} - // writeOutput writes data to the configured output. func (c *Config) writeOutput(data []byte) error { if c.outputAbsPath.Empty() || c.outputAbsPath == chezmoi.NewAbsPath("-") { _, err := c.stdout.Write(data) return err } - return c.baseSystem.WriteFile(c.outputAbsPath, data, 0o666) + return os.WriteFile(c.outputAbsPath.String(), data, 0o666) //nolint:gosec +} + +type writePathsOptions struct { + tree bool +} + +func (c *Config) writePaths(paths []string, options writePathsOptions) error { + builder := strings.Builder{} + if options.tree { + newPathListTreeFromPathsSlice(paths).writeChildren(&builder, "", " ") + } else { + sort.Strings(paths) + for _, path := range paths { + fmt.Fprintln(&builder, path) + } + } + return c.writeOutputString(builder.String()) } // writeOutputString writes data to the configured output. @@ -1981,10 +2705,211 @@ func (c *Config) writeOutputString(data string) error { return c.writeOutput([]byte(data)) } -// isDevVersion returns true if version is a development version (i.e. that the -// major, minor, and patch version numbers are all zero). -func isDevVersion(v *semver.Version) bool { - return v.Major == 0 && v.Minor == 0 && v.Patch == 0 +func newConfigFile(bds *xdg.BaseDirectorySpecification) ConfigFile { + return ConfigFile{ + // Global configuration. + CacheDirAbsPath: chezmoi.NewAbsPath(bds.CacheHome).Join(chezmoiRelPath), + Color: autoBool{ + auto: true, + }, + Interpreters: defaultInterpreters, + Mode: chezmoi.ModeFile, + Pager: os.Getenv("PAGER"), + Progress: autoBool{ + auto: true, + }, + PINEntry: pinEntryConfig{ + Options: pinEntryDefaultOptions, + }, + Safe: true, + TempDir: chezmoi.NewAbsPath(os.TempDir()), + Template: templateConfig{ + Options: chezmoi.DefaultTemplateOptions, + }, + Umask: chezmoi.Umask, + UseBuiltinAge: autoBool{ + auto: true, + }, + UseBuiltinGit: autoBool{ + auto: true, + }, + Warnings: warningsConfig{ + ConfigFileTemplateHasChanged: true, + }, + + // Password manager configurations. + Bitwarden: bitwardenConfig{ + Command: "bw", + }, + BitwardenSecrets: bitwardenSecretsConfig{ + Command: "bws", + }, + Dashlane: dashlaneConfig{ + Command: "dcli", + }, + Doppler: dopplerConfig{ + Command: "doppler", + }, + Ejson: ejsonConfig{ + KeyDir: firstNonEmptyString(os.Getenv("EJSON_KEYDIR"), "/opt/ejson/keys"), + }, + Gopass: gopassConfig{ + Command: "gopass", + }, + HCPVaultSecrets: hcpVaultSecretConfig{ + Command: "vlt", + }, + Keepassxc: keepassxcConfig{ + Command: "keepassxc-cli", + Prompt: true, + Mode: keepassxcModeCachePassword, + }, + Keeper: keeperConfig{ + Command: "keeper", + }, + Lastpass: lastpassConfig{ + Command: "lpass", + }, + Onepassword: onepasswordConfig{ + Command: "op", + Prompt: true, + Mode: onepasswordModeAccount, + }, + OnepasswordSDK: onepasswordSDKConfig{ + TokenEnvVar: "OP_SERVICE_ACCOUNT_TOKEN", + }, + Pass: passConfig{ + Command: "pass", + }, + Passhole: passholeConfig{ + Command: "ph", + Prompt: true, + }, + RBW: rbwConfig{ + Command: "rbw", + }, + Vault: vaultConfig{ + Command: "vault", + }, + + // Encryption configurations. + Age: defaultAgeEncryptionConfig, + GPG: defaultGPGEncryptionConfig, + + // Command configurations. + Add: addCmdConfig{ + Secrets: severityWarning, + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + recursive: true, + }, + Diff: diffCmdConfig{ + Exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), + Pager: defaultSentinel, + ScriptContents: true, + include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), + }, + Edit: editCmdConfig{ + Hardlink: true, + MinDuration: 1 * time.Second, + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + }, + Format: writeDataFormatJSON, + Git: gitCmdConfig{ + Command: "git", + }, + GitHub: gitHubConfig{ + RefreshPeriod: 1 * time.Minute, + }, + Merge: mergeCmdConfig{ + Command: "vimdiff", + }, + Status: statusCmdConfig{ + Exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), + PathStyle: chezmoi.PathStyleRelative.Copy(), + include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), + recursive: true, + }, + Update: updateCmdConfig{ + Apply: true, + RecurseSubmodules: true, + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + recursive: true, + }, + Verify: verifyCmdConfig{ + Exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), + include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), + recursive: true, + }, + } +} + +func (f *ConfigFile) toMap() map[string]any { + // Make a copy of f and replace any default sentinels with the empty string + // to ensure that there are no default sentinels in the result. + configFile := *f + if configFile.Diff.Pager == defaultSentinel { + configFile.Diff.Pager = "" + } + + // This is a horrible hack. We want the returned map to contain only simple + // types because they are used with masterminds/sprig template functions + // which don't accept fmt.Stringers in place of strings. As a work-around, + // round-trip via JSON. + data, err := json.Marshal(configFile) + if err != nil { + return nil + } + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + panic(err) + } + return result +} + +func parseCommand(command string, args []string) (string, []string, error) { + // If command is found, then return it. + if path, err := chezmoi.LookPath(command); err == nil { + return path, args, nil + } + + // Otherwise, if the command contains spaces, parse it as a shell command. + if whitespaceRx.MatchString(command) { + var words []*syntax.Word + if err := syntax.NewParser().Words(strings.NewReader(command), func(word *syntax.Word) bool { + words = append(words, word) + return true + }); err != nil { + return "", nil, err + } + switch fields, err := expand.Fields(&expand.Config{ + Env: expand.FuncEnviron(os.Getenv), + }, words...); { + case err != nil: + return "", nil, err + case len(fields) > 1: + return fields[0], append(fields[1:], args...), nil + case len(fields) == 1: + return fields[0], args, nil + } + } + + // Fallback to the command only. + return command, args, nil +} + +// registerCommonFlagCompletionFuncs registers completion functions for cmd's +// common flags, recursively. It panics on any error. +func registerCommonFlagCompletionFuncs(cmd *cobra.Command) { + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + if flagCompletionFunc, ok := commonFlagCompletionFuncs[flag.Name]; ok { + if err := cmd.RegisterFlagCompletionFunc(flag.Name, flagCompletionFunc); err != nil { + panic(err) + } + } + }) + for _, command := range cmd.Commands() { + registerCommonFlagCompletionFuncs(command) + } } // withVersionInfo sets the version information. @@ -2015,7 +2940,9 @@ func withVersionInfo(versionInfo VersionInfo) configOption { if versionInfo.BuiltBy != "" { versionElems = append(versionElems, "built by "+versionInfo.BuiltBy) } - c.version = version + if version != nil { + c.version = *version + } c.versionInfo = versionInfo c.versionStr = strings.Join(versionElems, ", ") return nil diff --git a/internal/cmd/config_tags_test.go b/internal/cmd/config_tags_test.go new file mode 100644 index 00000000000..a4128ed25a9 --- /dev/null +++ b/internal/cmd/config_tags_test.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "fmt" + "reflect" + "strings" + "testing" +) + +var expectedTags = []string{"json", "yaml", "mapstructure"} + +func TestExportedFieldsHaveMatchingMarshalTags(t *testing.T) { + failed, errMsg := verifyTagsArePresentAndMatch(reflect.TypeFor[ConfigFile]()) + if failed { + t.Error(errMsg) + } +} + +func fieldTypesNeedsVerification(ft reflect.Type) []reflect.Type { + kind := ft.Kind() + if kind < reflect.Array || kind == reflect.String { // its a ~scalar type + return []reflect.Type{} + } else if kind == reflect.Struct { + return []reflect.Type{ft} + } + switch kind { + case reflect.Pointer: + fallthrough + case reflect.Array: + fallthrough + case reflect.Slice: + return fieldTypesNeedsVerification(ft.Elem()) + case reflect.Map: + return append(fieldTypesNeedsVerification(ft.Key()), fieldTypesNeedsVerification(ft.Elem())...) + default: + return []reflect.Type{} // ... we'll assume interface types, funcs, channels are okay. + } +} + +func verifyTagsArePresentAndMatch(structType reflect.Type) (failed bool, errMsg string) { + name := structType.Name() + fields := reflect.VisibleFields(structType) + failed = false + + var errs strings.Builder + + for _, f := range fields { + if !f.IsExported() { + continue + } + + ts := f.Tag + tagValueGroups := make(map[string][]string) + + for _, tagName := range expectedTags { + tagValue, tagPresent := ts.Lookup(tagName) + + if !tagPresent { + errs.WriteString(fmt.Sprintf("\n%s field %s is missing a `%s:` tag", name, f.Name, tagName)) + failed = true + } + + matchingTags, notFirstOccurrence := tagValueGroups[tagValue] + if notFirstOccurrence { + tagValueGroups[tagValue] = append(matchingTags, tagName) + } else { + tagValueGroups[tagValue] = []string{tagName} + } + } + + if len(tagValueGroups) > 1 { + errs.WriteString(fmt.Sprintf("\n%s field %s has non-matching tag names:", name, f.Name)) + + for value, tagsMatching := range tagValueGroups { + if len(tagsMatching) == 1 { + errs.WriteString(fmt.Sprintf("\n %s says \"%s\"", tagsMatching[0], value)) + } else { + errs.WriteString(fmt.Sprintf("\n (%s) each say \"%s\"", strings.Join(tagsMatching, ", "), value)) + } + } + failed = true + } + + verifyTypes := fieldTypesNeedsVerification(f.Type) + for _, ft := range verifyTypes { + subFailed, subErrs := verifyTagsArePresentAndMatch(ft) + if subFailed { + errs.WriteString(fmt.Sprintf("\n In %s.%s:", name, f.Name)) + errs.WriteString(strings.ReplaceAll(subErrs, "\n", "\n ")) + failed = true + } + } + } + + return failed, errs.String() +} diff --git a/internal/cmd/config_test.go b/internal/cmd/config_test.go index 0f5d0db2754..098ee3f2134 100644 --- a/internal/cmd/config_test.go +++ b/internal/cmd/config_test.go @@ -1,21 +1,61 @@ package cmd import ( + "fmt" "io" "io/fs" "path/filepath" + "reflect" "runtime" + "strconv" + "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - vfs "github.com/twpayne/go-vfs/v4" + "github.com/alecthomas/assert/v2" + vfs "github.com/twpayne/go-vfs/v5" xdg "github.com/twpayne/go-xdg/v6" "github.com/twpayne/chezmoi/v2/internal/chezmoi" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) +func TestTagFieldNamesMatch(t *testing.T) { + fields := reflect.VisibleFields(reflect.TypeFor[ConfigFile]()) + expectedTags := []string{"json", "yaml", "mapstructure"} + + for _, f := range fields { + ts := f.Tag + tagValueGroups := make(map[string][]string) + + for _, tagName := range expectedTags { + tagValue, tagPresent := ts.Lookup(tagName) + + if !tagPresent { + t.Errorf("ConfigFile field %s is missing a %s tag", f.Name, tagName) + } + + matchingTags, notFirstOccurrence := tagValueGroups[tagValue] + if notFirstOccurrence { + tagValueGroups[tagValue] = append(matchingTags, tagName) + } else { + tagValueGroups[tagValue] = []string{tagName} + } + } + + if len(tagValueGroups) > 1 { + valueMsgs := []string{} + for value, tagsMatching := range tagValueGroups { + if len(tagsMatching) == 1 { + valueMsgs = append(valueMsgs, fmt.Sprintf("%s says \"%s\"", tagsMatching[0], value)) + } else { + valueMsgs = append(valueMsgs, fmt.Sprintf("(%s) each say \"%s\"", strings.Join(tagsMatching, ", "), value)) + } + } + t.Errorf("ConfigFile field %s has non-matching tag names:\n %s", f.Name, strings.Join(valueMsgs, "\n ")) + } + } +} + func TestAddTemplateFuncPanic(t *testing.T) { chezmoitest.WithTestFS(t, nil, func(fileSystem vfs.FS) { config := newTestConfig(t, fileSystem) @@ -28,6 +68,132 @@ func TestAddTemplateFuncPanic(t *testing.T) { }) } +func TestConfigFileFormatRoundTrip(t *testing.T) { + for _, format := range []chezmoi.Format{ + chezmoi.FormatJSON, + chezmoi.FormatYAML, + } { + t.Run(format.Name(), func(t *testing.T) { + configFile := ConfigFile{ + Color: autoBool{auto: true}, + Data: map[string]any{}, + Env: map[string]string{}, + Hooks: map[string]hookConfig{}, + Interpreters: map[string]chezmoi.Interpreter{}, + Mode: chezmoi.ModeFile, + PINEntry: pinEntryConfig{ + Args: []string{}, + Options: []string{}, + }, + ScriptEnv: map[string]string{}, + Template: templateConfig{ + Options: []string{}, + }, + TextConv: []*textConvElement{}, + UseBuiltinAge: autoBool{value: false}, + UseBuiltinGit: autoBool{value: true}, + Dashlane: dashlaneConfig{ + Args: []string{}, + }, + Doppler: dopplerConfig{ + Args: []string{}, + }, + HCPVaultSecrets: hcpVaultSecretConfig{ + Args: []string{}, + }, + Keepassxc: keepassxcConfig{ + Args: []string{}, + }, + Keeper: keeperConfig{ + Args: []string{}, + }, + Passhole: passholeConfig{ + Args: []string{}, + }, + Secret: secretConfig{ + Args: []string{}, + }, + Age: chezmoi.AgeEncryption{ + Args: []string{}, + Identity: chezmoi.NewAbsPath("/identity.txt"), + Identities: []chezmoi.AbsPath{}, + Recipients: []string{}, + RecipientsFiles: []chezmoi.AbsPath{}, + }, + GPG: chezmoi.GPGEncryption{ + Args: []string{}, + Recipients: []string{}, + }, + Add: addCmdConfig{ + Secrets: severityError, + }, + CD: cdCmdConfig{ + Args: []string{}, + }, + Diff: diffCmdConfig{ + Args: []string{}, + }, + Edit: editCmdConfig{ + Args: []string{}, + }, + Merge: mergeCmdConfig{ + Args: []string{}, + }, + Update: updateCmdConfig{ + Args: []string{}, + }, + } + data, err := format.Marshal(configFile) + assert.NoError(t, err) + var actualConfigFile ConfigFile + assert.NoError(t, format.Unmarshal(data, &actualConfigFile)) + assert.Equal(t, configFile, actualConfigFile) + }) + } +} + +func TestParseCommand(t *testing.T) { + for i, tc := range []struct { + command string + args []string + expectedCommand string + expectedArgs []string + expectedErr bool + }{ + { + command: "chezmoi-editor", + expectedCommand: "chezmoi-editor", + }, + { + command: `chezmoi-editor -f --nomru -c "au VimLeave * !open -a Terminal"`, + expectedCommand: "chezmoi-editor", + expectedArgs: []string{"-f", "--nomru", "-c", "au VimLeave * !open -a Terminal"}, + }, + { + command: `"chezmoi editor" $CHEZMOI_TEST_VAR`, + args: []string{"extra-arg"}, + expectedCommand: "chezmoi editor", + expectedArgs: []string{"chezmoi-test-value", "extra-arg"}, + }, + { + command: `"chezmoi editor`, + expectedErr: true, + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + t.Setenv("CHEZMOI_TEST_VAR", "chezmoi-test-value") + actualCommand, actualArgs, err := parseCommand(tc.command, tc.args) + if tc.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedCommand, actualCommand) + assert.Equal(t, tc.expectedArgs, actualArgs) + } + }) + } +} + func TestParseConfig(t *testing.T) { for _, tc := range []struct { name string @@ -89,17 +255,41 @@ func TestParseConfig(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - chezmoitest.WithTestFS(t, map[string]interface{}{ + chezmoitest.WithTestFS(t, map[string]any{ "/home/user/.config/chezmoi/" + tc.filename: tc.contents, }, func(fileSystem vfs.FS) { c := newTestConfig(t, fileSystem) - require.NoError(t, c.execute([]string{"init"})) + assert.NoError(t, c.execute([]string{"init"})) assert.Equal(t, tc.expectedColor, c.Color.Value(c.colorAutoFunc)) }) }) } } +func TestInitConfigWithIncludedTemplate(t *testing.T) { + mainFilename := ".chezmoi.yaml.tmpl" + secondaryFilename := "personal.config.yaml.tmpl" + mainContents := chezmoitest.JoinLines( + `color: true`, + fmt.Sprintf(`{{ includeTemplate %q . }}`, secondaryFilename), + ) + secondaryContents := chezmoitest.JoinLines( + `verbose: true`, + `safe: {{ stdinIsATTY }}`, + ) + + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user/.local/share/chezmoi/" + mainFilename: mainContents, + "/home/user/.local/share/chezmoi/" + secondaryFilename: secondaryContents, + }, func(fileSystem vfs.FS) { + c := newTestConfig(t, fileSystem) + assert.NoError(t, c.execute([]string{"init"})) + assert.True(t, c.Color.Value(c.colorAutoFunc)) + assert.True(t, c.Verbose) + assert.False(t, c.Safe) + }) +} + func TestUpperSnakeCaseToCamelCase(t *testing.T) { for s, expected := range map[string]string{ "BUG_REPORT_URL": "bugReportURL", @@ -113,58 +303,6 @@ func TestUpperSnakeCaseToCamelCase(t *testing.T) { } } -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, fileSystem vfs.FS, options ...configOption) *Config { t.Helper() system := chezmoi.NewRealSystem(fileSystem) @@ -176,9 +314,12 @@ func newTestConfig(t *testing.T, fileSystem vfs.FS, options ...configOption) *Co withTestFS(fileSystem), withTestUser(t, "user"), withUmask(chezmoitest.Umask), + withVersionInfo(VersionInfo{ + Version: "2.0.0", + }), }, options...)..., ) - require.NoError(t, err) + assert.NoError(t, err) return config } @@ -196,6 +337,13 @@ func withDestSystem(destSystem chezmoi.System) configOption { } } +func withNoTTY(noTTY bool) configOption { //nolint:unparam + return func(c *Config) error { + c.noTTY = noTTY + return nil + } +} + func withSourceSystem(sourceSystem chezmoi.System) configOption { return func(c *Config) error { c.sourceSystem = sourceSystem @@ -239,11 +387,11 @@ func withTestUser(t *testing.T, username string) configOption { config.homeDir = filepath.Join("/", "home", username) env = "HOME" } - testSetenv(t, env, config.homeDir) + t.Setenv(env, config.homeDir) var err error config.homeDirAbsPath, err = chezmoi.NormalizePath(config.homeDir) if err != nil { - panic(err) + t.Fatal(err) } config.CacheDirAbsPath = config.homeDirAbsPath.JoinString(".cache", "chezmoi") config.SourceDirAbsPath = config.homeDirAbsPath.JoinString(".local", "share", "chezmoi") diff --git a/internal/cmd/config_unix.go b/internal/cmd/config_unix.go deleted file mode 100644 index d15283b1e0b..00000000000 --- a/internal/cmd/config_unix.go +++ /dev/null @@ -1,44 +0,0 @@ -//go:build !windows -// +build !windows - -package cmd - -import ( - "errors" - "os" - - "go.uber.org/multierr" - "golang.org/x/term" -) - -// readPassword reads a password. -func (c *Config) readPassword(prompt string) (password string, err error) { - if c.noTTY { - password, err = c.readLine(prompt) - return - } - - if c.PINEntry.Command != "" { - return c.readPINEntry(prompt) - } - - var tty *os.File - if tty, err = os.OpenFile("/dev/tty", os.O_RDWR, 0); err != nil { - return - } - defer func() { - err = multierr.Append(err, tty.Close()) - }() - if _, err = tty.Write([]byte(prompt)); err != nil { - return - } - var passwordBytes []byte - if passwordBytes, err = term.ReadPassword(int(tty.Fd())); err != nil && !errors.Is(err, term.ErrPasteIndicator) { - return - } - if _, err = tty.Write([]byte{'\n'}); err != nil { - return - } - password = string(passwordBytes) - return -} diff --git a/internal/cmd/config_windows.go b/internal/cmd/config_windows.go deleted file mode 100644 index 73101622014..00000000000 --- a/internal/cmd/config_windows.go +++ /dev/null @@ -1,44 +0,0 @@ -package cmd - -import ( - "fmt" - - "go.uber.org/multierr" - "golang.org/x/sys/windows" - "golang.org/x/term" -) - -// readPassword reads a password. -func (c *Config) readPassword(prompt string) (password string, err error) { - if c.noTTY { - password, err = c.readLine(prompt) - return - } - - if c.PINEntry.Command != "" { - return c.readPINEntry(prompt) - } - - var name *uint16 - name, err = windows.UTF16PtrFromString("CONIN$") - if err != nil { - return - } - var handle windows.Handle - if handle, err = windows.CreateFile(name, windows.GENERIC_READ|windows.GENERIC_WRITE, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, 0, 0); err != nil { - return - } - defer func() { - err = multierr.Append(err, windows.CloseHandle(handle)) - }() - //nolint:forbidigo - fmt.Print(prompt) - var passwordBytes []byte - if passwordBytes, err = term.ReadPassword(int(handle)); err != nil { - return - } - //nolint:forbidigo - fmt.Println("") - password = string(passwordBytes) - return -} diff --git a/internal/cmd/dashlanetemplatefuncs.go b/internal/cmd/dashlanetemplatefuncs.go new file mode 100644 index 00000000000..52e618023cf --- /dev/null +++ b/internal/cmd/dashlanetemplatefuncs.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "encoding/json" + "os" + "os/exec" + "slices" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +type dashlaneConfig struct { + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + cacheNote map[string]any + cachePassword map[string]any +} + +func (c *Config) dashlaneNoteTemplateFunc(filter string) any { + if data, ok := c.Dashlane.cacheNote[filter]; ok { + return data + } + + if c.Dashlane.cacheNote == nil { + c.Dashlane.cacheNote = make(map[string]any) + } + + output, err := c.dashlaneOutput("note", filter) + if err != nil { + panic(err) + } + + data := string(output) + + c.Dashlane.cacheNote[filter] = data + return data +} + +func (c *Config) dashlanePasswordTemplateFunc(filter string) any { + if data, ok := c.Dashlane.cachePassword[filter]; ok { + return data + } + + if c.Dashlane.cachePassword == nil { + c.Dashlane.cachePassword = make(map[string]any) + } + + output, err := c.dashlaneOutput("password", "--output", "json", filter) + if err != nil { + panic(err) + } + + var data any + if err := json.Unmarshal(output, &data); err != nil { + panic(err) + } + + c.Dashlane.cachePassword[filter] = data + return data +} + +func (c *Config) dashlaneOutput(args ...string) ([]byte, error) { + name := c.Dashlane.Command + args = append(slices.Clone(c.Dashlane.Args), args...) + cmd := exec.Command(name, args...) + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) + if err != nil { + return nil, err + } + return output, nil +} diff --git a/internal/cmd/datacmd.go b/internal/cmd/datacmd.go index 3e932012c22..6a3b901b8b1 100644 --- a/internal/cmd/datacmd.go +++ b/internal/cmd/datacmd.go @@ -6,26 +6,28 @@ import ( "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) -type dataCmdConfig struct { - format writeDataFormat -} - 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), + Use: "data", + Short: "Print the template data", + Long: mustLongHelp("data"), + Example: example("data"), + Args: cobra.NoArgs, + RunE: c.runDataCmd, + Annotations: newAnnotations(), } - persistentFlags := dataCmd.PersistentFlags() - persistentFlags.VarP(&c.data.format, "format", "f", "format") + dataCmd.Flags().VarP(&c.Format, "format", "f", "Output format") return dataCmd } -func (c *Config) runDataCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { - return c.marshal(c.data.format, sourceState.TemplateData()) +func (c *Config) runDataCmd(cmd *cobra.Command, args []string) error { + sourceState, err := c.newSourceState(cmd.Context(), cmd, + chezmoi.WithTemplateDataOnly(true), + ) + if err != nil { + return err + } + return c.marshal(c.Format, sourceState.TemplateData()) } diff --git a/internal/cmd/datacmd_test.go b/internal/cmd/datacmd_test.go index 8f7f0e76147..b5231ae1e2c 100644 --- a/internal/cmd/datacmd_test.go +++ b/internal/cmd/datacmd_test.go @@ -4,9 +4,8 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/twpayne/go-vfs/v4" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" "github.com/twpayne/chezmoi/v2/internal/chezmoi" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" @@ -15,14 +14,21 @@ import ( func TestDataCmd(t *testing.T) { for _, tc := range []struct { format chezmoi.Format - root map[string]interface{} + root map[string]any }{ { format: chezmoi.FormatJSON, - root: map[string]interface{}{ + root: map[string]any{ "/home/user/.config/chezmoi/chezmoi.json": chezmoitest.JoinLines( `{`, + ` "mode": "symlink",`, ` "sourceDir": "/tmp/source",`, + ` "age": {`, + ` "args": [`, + ` "arg"`, + ` ],`, + ` "identity": "/my-age-identity"`, + ` },`, ` "data": {`, ` "test": true`, ` }`, @@ -32,9 +38,14 @@ func TestDataCmd(t *testing.T) { }, { format: chezmoi.FormatYAML, - root: map[string]interface{}{ + root: map[string]any{ "/home/user/.config/chezmoi/chezmoi.yaml": chezmoitest.JoinLines( + `mode: symlink`, `sourceDir: /tmp/source`, + `age:`, + ` args:`, + ` - arg`, + ` identity: /my-age-identity`, `data:`, ` test: true`, ), @@ -47,20 +58,31 @@ func TestDataCmd(t *testing.T) { "data", "--format", tc.format.Name(), } - config := newTestConfig(t, fileSystem) - builder := strings.Builder{} - config.stdout = &builder - require.NoError(t, config.execute(args)) + stdout := strings.Builder{} + config := newTestConfig(t, fileSystem, withStdout(&stdout)) + assert.NoError(t, config.execute(args)) var data struct { Chezmoi struct { + Config struct { + Age struct { + Args []string `json:"args" yaml:"args"` + Identity string `json:"identity" yaml:"identity"` + } `json:"age" yaml:"age"` + Mode string `json:"mode" yaml:"mode"` + } `json:"config" yaml:"config"` SourceDir string `json:"sourceDir" yaml:"sourceDir"` } `json:"chezmoi" yaml:"chezmoi"` - Test bool `json:"test" yaml:"test"` + Test bool `json:"test" yaml:"test"` } - assert.NoError(t, tc.format.Unmarshal([]byte(builder.String()), &data)) + assert.NoError(t, tc.format.Unmarshal([]byte(stdout.String()), &data)) + assert.Equal(t, []string{"arg"}, data.Chezmoi.Config.Age.Args) + normalizedAgeIdentity, err := chezmoi.NormalizePath("/my-age-identity") + assert.NoError(t, err) + assert.Equal(t, normalizedAgeIdentity.String(), data.Chezmoi.Config.Age.Identity) + assert.Equal(t, "symlink", data.Chezmoi.Config.Mode) normalizedSourceDir, err := chezmoi.NormalizePath("/tmp/source") - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, normalizedSourceDir.String(), data.Chezmoi.SourceDir) assert.True(t, data.Test) }) diff --git a/internal/cmd/dataformat.go b/internal/cmd/dataformat.go index a694ce70d95..292ba142d28 100644 --- a/internal/cmd/dataformat.go +++ b/internal/cmd/dataformat.go @@ -3,6 +3,8 @@ package cmd import ( "errors" "strings" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) // A readDataFormat is a format that chezmoi uses for reading (JSON, TOML, or @@ -24,10 +26,19 @@ const ( writeDataFormatJSON writeDataFormat = "json" writeDataFormatYAML writeDataFormat = "yaml" - - defaultWriteDataFormat = writeDataFormatJSON ) +var readDataFormatFlagCompletionFunc = chezmoi.FlagCompletionFunc([]string{ + string(readDataFormatJSON), + string(readDataFormatTOML), + string(readDataFormatYAML), +}) + +var writeDataFormatFlagCompletionFunc = chezmoi.FlagCompletionFunc([]string{ + string(writeDataFormatJSON), + string(writeDataFormatYAML), +}) + // Set implements github.com/spf13/pflag.Value.Set. func (f *readDataFormat) Set(s string) error { switch strings.ToLower(s) { @@ -43,6 +54,11 @@ func (f *readDataFormat) Set(s string) error { return nil } +// Format returns f's format. +func (f readDataFormat) Format() chezmoi.Format { + return chezmoi.FormatsByName[string(f)] +} + // String implements github.com/spf13/pflag.Value.String. func (f readDataFormat) String() string { return string(f) @@ -53,6 +69,11 @@ func (f readDataFormat) Type() string { return "json|toml|yaml" } +// Format returns f's format. +func (f writeDataFormat) Format() chezmoi.Format { + return chezmoi.FormatsByName[string(f)] +} + // Set implements github.com/spf13/pflag.Value.Set. func (f *writeDataFormat) Set(s string) error { switch strings.ToLower(s) { diff --git a/internal/cmd/decryptcmd.go b/internal/cmd/decryptcmd.go index bcb65cd25e2..5c8310af31d 100644 --- a/internal/cmd/decryptcmd.go +++ b/internal/cmd/decryptcmd.go @@ -5,15 +5,16 @@ import ( ) func (c *Config) newDecryptCommand() *cobra.Command { - decryptCommand := &cobra.Command{ - Use: "decrypt [file...]", - Short: "Decrypt file or standard input", - Long: mustLongHelp("decrypt"), - Example: example("decrypt"), - RunE: c.runDecryptCmd, + decryptCmd := &cobra.Command{ + Use: "decrypt [file...]", + Short: "Decrypt file or standard input", + Long: mustLongHelp("decrypt"), + Example: example("decrypt"), + RunE: c.runDecryptCmd, + Annotations: newAnnotations(), } - return decryptCommand + return decryptCmd } func (c *Config) runDecryptCmd(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/destroycmd.go b/internal/cmd/destroycmd.go new file mode 100644 index 00000000000..1e8ae43d41c --- /dev/null +++ b/internal/cmd/destroycmd.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "errors" + "fmt" + "io/fs" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +type destroyCmdConfig struct { + recursive bool +} + +func (c *Config) newDestroyCmd() *cobra.Command { + destroyCmd := &cobra.Command{ + Use: "destroy target...", + Short: "Permanently delete an entry from the source state, the destination directory, and the state", + Long: mustLongHelp("destroy"), + Example: example("destroy"), + ValidArgsFunction: c.targetValidArgs, + Args: cobra.MinimumNArgs(1), + RunE: c.makeRunEWithSourceState(c.runDestroyCmd), + Annotations: newAnnotations( + modifiesDestinationDirectory, + modifiesSourceDirectory, + persistentStateModeReadWrite, + ), + } + + destroyCmd.Flags().BoolVarP(&c.destroy.recursive, "recursive", "r", c.destroy.recursive, "Recurse into subdirectories") + + return destroyCmd +} + +func (c *Config) runDestroyCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + targetRelPaths, err := c.targetRelPaths(sourceState, args, &targetRelPathsOptions{ + recursive: c.destroy.recursive, + }) + if err != nil { + return err + } + + for _, targetRelPath := range targetRelPaths { + destAbsPath := c.DestDirAbsPath.Join(targetRelPath) + // Find the path of the entry in the source state, if any. + // + // chezmoi destroy might be called on an entry in an exact_ directory + // that is not present in the source directory. The entry is still + // managed by chezmoi because chezmoi apply will remove it. Therefore, + // chezmoi destroy should remove such entries from the target state, + // even if they are not present in the source state. So, when calling + // chezmoi destroy on entries like this, we should only remove the entry + // from the target state, not the source state. + // + // For entries in exact_ directories in the target state that are not + // present in the source state, we generate SourceStateRemove entries. + // So, if the source state entry is a SourceStateRemove then we know + // that there is no actual source state entry to remove. + var sourceAbsPath chezmoi.AbsPath + sourceStateEntry := sourceState.MustEntry(targetRelPath) + if _, ok := sourceStateEntry.(*chezmoi.SourceStateRemove); !ok { + sourceAbsPath = c.SourceDirAbsPath.Join(sourceStateEntry.SourceRelPath().RelPath()) + } + if !c.force { + var prompt string + if sourceAbsPath.Empty() { + prompt = fmt.Sprintf("Destroy %s", destAbsPath) + } else { + prompt = fmt.Sprintf("Destroy %s and %s", destAbsPath, sourceAbsPath) + } + choice, err := c.promptChoice(prompt, choicesYesNoAllQuit) + if err != nil { + return err + } + switch choice { + case "yes": + case "no": + continue + case "all": + c.force = true + case "quit": + return nil + } + } + if err := c.destSystem.RemoveAll(destAbsPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + if !sourceAbsPath.Empty() { + if err := c.sourceSystem.RemoveAll(sourceAbsPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } + if err := c.persistentState.Delete(chezmoi.EntryStateBucket, destAbsPath.Bytes()); err != nil { + return err + } + } + return nil +} diff --git a/internal/cmd/diffcmd.go b/internal/cmd/diffcmd.go index 2595cd7acae..ab61014f5f9 100644 --- a/internal/cmd/diffcmd.go +++ b/internal/cmd/diffcmd.go @@ -1,84 +1,56 @@ package cmd import ( - "strings" - "github.com/spf13/cobra" - "go.uber.org/multierr" "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) type diffCmdConfig struct { - Command string `mapstructure:"command"` - Args []string `mapstructure:"args"` - Exclude *chezmoi.EntryTypeSet `mapstructure:"exclude"` - Pager string `mapstructure:"pager"` + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + Exclude *chezmoi.EntryTypeSet `json:"exclude" mapstructure:"exclude" yaml:"exclude"` + Pager string `json:"pager" mapstructure:"pager" yaml:"pager"` + Reverse bool `json:"reverse" mapstructure:"reverse" yaml:"reverse"` + ScriptContents bool `json:"scriptContents" mapstructure:"scriptContents" yaml:"scriptContents"` include *chezmoi.EntryTypeSet init bool recursive bool - reverse bool - useBuiltinDiff bool } 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, - }, + Use: "diff [target]...", + Short: "Print the diff between the target state and the destination state", + Long: mustLongHelp("diff"), + Example: example("diff"), + ValidArgsFunction: c.targetValidArgs, + RunE: c.runDiffCmd, + Annotations: newAnnotations( + dryRun, + outputsDiff, + persistentStateModeReadMockWrite, + requiresSourceDirectory, + ), } - flags := diffCmd.Flags() - flags.VarP(c.Diff.Exclude, "exclude", "x", "Exclude entry types") - flags.VarP(c.Diff.include, "include", "i", "Include entry types") - flags.BoolVar(&c.Diff.init, "init", c.update.init, "Recreate config file from template") - flags.BoolVarP(&c.Diff.recursive, "recursive", "r", c.Diff.recursive, "Recurse into subdirectories") - flags.BoolVar(&c.Diff.reverse, "reverse", c.Diff.reverse, "Reverse the direction of the diff") - flags.StringVar(&c.Diff.Pager, "pager", c.Diff.Pager, "Set pager") - flags.BoolVarP(&c.Diff.useBuiltinDiff, "use-builtin-diff", "", c.Diff.useBuiltinDiff, "Use the builtin diff") + diffCmd.Flags().VarP(c.Diff.Exclude, "exclude", "x", "Exclude entry types") + diffCmd.Flags().VarP(c.Diff.include, "include", "i", "Include entry types") + diffCmd.Flags().BoolVar(&c.Diff.init, "init", c.Diff.init, "Recreate config file from template") + diffCmd.Flags().StringVar(&c.Diff.Pager, "pager", c.Diff.Pager, "Set pager") + diffCmd.Flags().BoolVarP(&c.Diff.recursive, "recursive", "r", c.Diff.recursive, "Recurse into subdirectories") + diffCmd.Flags().BoolVar(&c.Diff.Reverse, "reverse", c.Diff.Reverse, "Reverse the direction of the diff") + diffCmd.Flags().BoolVar(&c.Diff.ScriptContents, "script-contents", c.Diff.ScriptContents, "Show script contents") return diffCmd } func (c *Config) runDiffCmd(cmd *cobra.Command, args []string) (err error) { - builder := strings.Builder{} - dryRunSystem := chezmoi.NewDryRunSystem(c.destSystem) - if c.Diff.useBuiltinDiff || c.Diff.Command == "" { - color := c.Color.Value(c.colorAutoFunc) - gitDiffSystem := chezmoi.NewGitDiffSystem(dryRunSystem, &builder, c.DestDirAbsPath, &chezmoi.GitDiffSystemOptions{ - Color: color, - Include: c.Diff.include.Sub(c.Diff.Exclude), - Reverse: c.Diff.reverse, - }) - if err = c.applyArgs(cmd.Context(), gitDiffSystem, c.DestDirAbsPath, args, applyArgsOptions{ - include: c.Diff.include.Sub(c.Diff.Exclude), - init: c.Diff.init, - recursive: c.Diff.recursive, - umask: c.Umask, - }); err != nil { - return - } - err = c.pageOutputString(builder.String(), c.Diff.Pager) - return - } - diffSystem := chezmoi.NewExternalDiffSystem( - dryRunSystem, c.Diff.Command, c.Diff.Args, c.DestDirAbsPath, &chezmoi.ExternalDiffSystemOptions{ - Reverse: c.Diff.reverse, - }, - ) - defer func() { - err = multierr.Append(err, diffSystem.Close()) - }() - err = c.applyArgs(cmd.Context(), diffSystem, c.DestDirAbsPath, args, applyArgsOptions{ - include: c.Diff.include.Sub(c.Diff.Exclude), + return c.applyArgs(cmd.Context(), c.destSystem, c.DestDirAbsPath, args, applyArgsOptions{ + cmd: cmd, + filter: chezmoi.NewEntryTypeFilter(c.Diff.include.Bits(), c.Diff.Exclude.Bits()), init: c.Diff.init, recursive: c.Diff.recursive, umask: c.Umask, }) - return } diff --git a/internal/cmd/diffcmd_test.go b/internal/cmd/diffcmd_test.go index 233833608e1..6ec4b78e0a4 100644 --- a/internal/cmd/diffcmd_test.go +++ b/internal/cmd/diffcmd_test.go @@ -1,13 +1,13 @@ package cmd import ( + "io/fs" "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -18,7 +18,7 @@ func TestDiffCmd(t *testing.T) { } for _, tc := range []struct { name string - extraRoot interface{} + extraRoot any args []string stdoutStr string }{ @@ -27,8 +27,8 @@ func TestDiffCmd(t *testing.T) { }, { name: "file", - extraRoot: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + extraRoot: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ "dot_file": "# contents of .file\n", }, }, @@ -44,8 +44,8 @@ func TestDiffCmd(t *testing.T) { }, { name: "simple_exclude_files", - extraRoot: map[string]interface{}{ - "/home/user/.local/share/chezmoi": map[string]interface{}{ + extraRoot: map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ "dot_file": "# contents of .file\n", "symlink_dot_symlink": ".file\n", }, @@ -65,13 +65,13 @@ func TestDiffCmd(t *testing.T) { }, { name: "simple_exclude_files_with_config", - extraRoot: map[string]interface{}{ - "/home/user": map[string]interface{}{ + extraRoot: map[string]any{ + "/home/user": map[string]any{ ".config/chezmoi/chezmoi.toml": chezmoitest.JoinLines( `[diff]`, - ` exclude = ["files"]`, + ` exclude = ["files"]`, ), - ".local/share/chezmoi": map[string]interface{}{ + ".local/share/chezmoi": map[string]any{ "dot_file": "# contents of .file\n", "symlink_dot_symlink": ".file\n", }, @@ -87,16 +87,50 @@ func TestDiffCmd(t *testing.T) { `+.file`, ), }, + { + name: "simple_exclude_externals_with_config", + extraRoot: map[string]any{ + "/home/user": map[string]any{ + ".config/chezmoi/chezmoi.toml": chezmoitest.JoinLines( + `[diff]`, + ` exclude = ["externals"]`, + ), + ".local/share/chezmoi": map[string]any{ + "dot_file": "# contents of .file\n", + "symlink_dot_symlink": ".file\n", + }, + }, + }, + stdoutStr: chezmoitest.JoinLines( + `diff --git a/.file b/.file`, + `new file mode 100644`, + `index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362`, + `--- /dev/null`, + `+++ b/.file`, + `@@ -0,0 +1 @@`, + `+# contents of .file`, + `diff --git a/.symlink b/.symlink`, + `new file mode 120000`, + `index 0000000000000000000000000000000000000000..3e6844d17780d623d817c3e22bcd1128d64422ae`, + `--- /dev/null`, + `+++ b/.symlink`, + `@@ -0,0 +1 @@`, + `+.file`, + ), + }, } { t.Run(tc.name, func(t *testing.T) { - chezmoitest.WithTestFS(t, map[string]interface{}{ - "/home/user": &vfst.Dir{Perm: 0o777 &^ chezmoitest.Umask}, + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user/.local/share/chezmoi": &vfst.Dir{ + Perm: fs.ModePerm &^ chezmoitest.Umask, + }, }, func(fileSystem vfs.FS) { if tc.extraRoot != nil { - require.NoError(t, vfst.NewBuilder().Build(fileSystem, tc.extraRoot)) + assert.NoError(t, vfst.NewBuilder().Build(fileSystem, tc.extraRoot)) } stdout := strings.Builder{} - require.NoError(t, newTestConfig(t, fileSystem, withStdout(&stdout)).execute(append([]string{"diff"}, tc.args...))) + config := newTestConfig(t, fileSystem, withStdout(&stdout)) + assert.NoError(t, config.execute(append([]string{"diff"}, tc.args...))) assert.Equal(t, tc.stdoutStr, stdout.String()) }) }) diff --git a/internal/cmd/docscmd.go b/internal/cmd/docscmd.go deleted file mode 100644 index 7a52a82090f..00000000000 --- a/internal/cmd/docscmd.go +++ /dev/null @@ -1,118 +0,0 @@ -package cmd - -import ( - "fmt" - "io" - "io/fs" - "os" - "regexp" - "strings" - - "github.com/charmbracelet/glamour" - "github.com/spf13/cobra" - "go.uber.org/multierr" - "golang.org/x/term" - - "github.com/twpayne/chezmoi/v2/docs" -) - -type docsCmdConfig struct { - MaxWidth int `mapstructure:"maxWidth"` - Pager string `mapstructure:"pager"` -} - -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", - }, - } - - flags := docsCmd.Flags() - flags.IntVar(&c.Docs.MaxWidth, "max-width", c.Docs.MaxWidth, "Set maximum output width") - flags.StringVar(&c.Docs.Pager, "pager", c.Docs.Pager, "Set pager") - - return docsCmd -} - -func (c *Config) runDocsCmd(cmd *cobra.Command, args []string) (err error) { - filename := "REFERENCE.md" - if len(args) > 0 { - pattern := args[0] - var re *regexp.Regexp - if re, err = regexp.Compile(strings.ToLower(pattern)); err != nil { - return - } - var dirEntries []fs.DirEntry - if dirEntries, err = docs.FS.ReadDir("."); err != nil { - return - } - var filenames []string - for _, dirEntry := range dirEntries { - var fileInfo fs.FileInfo - if fileInfo, err = dirEntry.Info(); err != nil { - return - } - if fileInfo.Mode().Type() != 0 { - continue - } - if filename := dirEntry.Name(); re.FindStringIndex(strings.ToLower(filename)) != nil { - filenames = append(filenames, filename) - } - } - switch { - case len(filenames) == 0: - err = fmt.Errorf("%s: no matching files", pattern) - return - case len(filenames) == 1: - filename = filenames[0] - default: - err = fmt.Errorf("%s: ambiguous pattern, matches %s", pattern, strings.Join(filenames, ", ")) - return - } - } - - var file fs.File - if file, err = docs.FS.Open(filename); err != nil { - return - } - defer func() { - err = multierr.Append(err, file.Close()) - }() - var documentData []byte - if documentData, err = io.ReadAll(file); err != nil { - return - } - - width := 80 - if stdout, ok := c.stdout.(*os.File); ok && term.IsTerminal(int(stdout.Fd())) { - if width, _, err = term.GetSize(int(stdout.Fd())); err != nil { - return - } - } - if c.Docs.MaxWidth != 0 && width > c.Docs.MaxWidth { - width = c.Docs.MaxWidth - } - - var termRenderer *glamour.TermRenderer - if termRenderer, err = glamour.NewTermRenderer( - glamour.WithStyles(glamour.ASCIIStyleConfig), - glamour.WithWordWrap(width), - ); err != nil { - return - } - - var renderedData []byte - if renderedData, err = termRenderer.RenderBytes(documentData); err != nil { - return err - } - - err = c.pageOutputString(string(renderedData), c.Docs.Pager) - return -} diff --git a/internal/cmd/doctorcmd.go b/internal/cmd/doctorcmd.go index c15393128f0..b29692788c3 100644 --- a/internal/cmd/doctorcmd.go +++ b/internal/cmd/doctorcmd.go @@ -1,10 +1,15 @@ package cmd +// FIXME add check for $TMPDIR mount options (specifically noexec) + import ( "bytes" + "context" "errors" "fmt" "io/fs" + "log/slog" + "net/http" "os" "os/exec" "regexp" @@ -12,27 +17,41 @@ import ( "sort" "strings" "text/tabwriter" + "time" "github.com/coreos/go-semver/semver" + "github.com/google/go-github/v63/github" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/twpayne/go-shell" "github.com/twpayne/go-xdg/v6" "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoigit" "github.com/twpayne/chezmoi/v2/internal/chezmoilog" + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" ) // A checkResult is the result of a check. type checkResult int const ( + checkResultOmitted checkResult = -3 // The check was omitted. + checkResultFailed checkResult = -2 // The check could not be completed. checkResultSkipped checkResult = -1 // The check was skipped. checkResultOK checkResult = 0 // The check completed and did not find any problems. checkResultInfo checkResult = 1 // The check completed and found something interesting, but not a problem. checkResultWarning checkResult = 2 // The check completed and found something that might indicate a problem. checkResultError checkResult = 3 // The check completed and found a definite problem. - checkResultFailed checkResult = 4 // The check could not be completed. +) + +// A gitStatus is the status of a git working copy. +type gitStatus string + +const ( + gitStatusNotAWorkingCopy gitStatus = "" + gitStatusClean gitStatus = "clean" + gitStatusDirty gitStatus = "dirty" + gitStatusError gitStatus = "error" ) // A check is an individual check. @@ -42,19 +61,27 @@ type check interface { } var checkResultStr = map[checkResult]string{ + checkResultOmitted: "omitted", + checkResultFailed: "failed", checkResultSkipped: "skipped", checkResultOK: "ok", checkResultInfo: "info", checkResultWarning: "warning", checkResultError: "error", - checkResultFailed: "failed", +} + +// An argsCheck checks that arguments for a binary. +type argsCheck struct { + name string + command string + args []string } // A binaryCheck checks that a binary called name is installed and optionally at // least version minVersion. type binaryCheck struct { name string - binaryname string + binaryName string ifNotSet checkResult ifNotExist checkResult versionArgs []string @@ -87,18 +114,30 @@ type fileCheck struct { ifNotExist checkResult } +// A goVersionCheck checks the Go version. +type goVersionCheck struct{} + +// A latestVersionCheck checks the latest version. +type latestVersionCheck struct { + network bool + httpClient *http.Client + httpClientErr error + version semver.Version +} + // An osArchCheck checks that runtime.GOOS and runtime.GOARCH are supported. type osArchCheck struct{} +// A omittedCheck is a check that is omitted. +type omittedCheck struct{} + // A suspiciousEntriesCheck checks that a source directory does not contain any // suspicious files. type suspiciousEntriesCheck struct { - dirname chezmoi.AbsPath + dirname chezmoi.AbsPath + encryptedSuffixes []string } -// A umaskCheck checks the umask. -type umaskCheck struct{} - // A upgradeMethodCheck checks the upgrade method. type upgradeMethodCheck struct{} @@ -108,6 +147,10 @@ type versionCheck struct { versionStr string } +type doctorCmdConfig struct { + noNetwork bool +} + func (c *Config) newDoctorCmd() *cobra.Command { doctorCmd := &cobra.Command{ Args: cobra.NoArgs, @@ -116,12 +159,14 @@ func (c *Config) newDoctorCmd() *cobra.Command { Example: example("doctor"), Long: mustLongHelp("doctor"), RunE: c.runDoctorCmd, - Annotations: map[string]string{ - doesNotRequireValidConfig: "true", - runsCommands: "true", - }, + Annotations: newAnnotations( + doesNotRequireValidConfig, + runsCommands, + ), } + doctorCmd.PersistentFlags().BoolVar(&c.doctor.noNetwork, "no-network", c.doctor.noNetwork, "do not use network connection") + return doctorCmd } @@ -130,20 +175,32 @@ func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { if err != nil { return err } - shell, _ := shell.CurrentUserShell() - editCommand, _ := c.editor(nil) + httpClient, httpClientErr := c.getHTTPClient() + shellCommand, _ := shell.CurrentUserShell() + shellCommand, shellArgs, _ := parseCommand(shellCommand, nil) + cdCommand, cdArgs, _ := c.cdCommand() + editCommand, editArgs, _ := c.editor(nil) checks := []check{ &versionCheck{ versionInfo: c.versionInfo, versionStr: c.versionStr, }, - &osArchCheck{}, - &executableCheck{}, - &upgradeMethodCheck{}, + &latestVersionCheck{ + network: !c.doctor.noNetwork, + httpClient: httpClient, + httpClientErr: httpClientErr, + version: c.version, + }, + osArchCheck{}, + unameCheck{}, + systeminfoCheck{}, + goVersionCheck{}, + executableCheck{}, + upgradeMethodCheck{}, &configFileCheck{ basename: chezmoiRelPath, bds: c.bds, - expected: c.configFileAbsPath, + expected: c.getConfigFileAbsPath(), }, &dirCheck{ name: "source-dir", @@ -151,6 +208,10 @@ func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { }, &suspiciousEntriesCheck{ dirname: c.SourceDirAbsPath, + encryptedSuffixes: []string{ + c.Age.Suffix, + c.GPG.Suffix, + }, }, &dirCheck{ name: "working-tree", @@ -160,22 +221,38 @@ func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { name: "dest-dir", dirname: c.DestDirAbsPath, }, + umaskCheck{}, &binaryCheck{ - name: "shell", - binaryname: shell, + name: "cd-command", + binaryName: cdCommand, ifNotSet: checkResultError, ifNotExist: checkResultError, }, + &argsCheck{ + name: "cd-args", + command: cdCommand, + args: cdArgs, + }, + &binaryCheck{ + name: "diff-command", + binaryName: c.Diff.Command, + ifNotSet: checkResultInfo, + ifNotExist: checkResultWarning, + }, &binaryCheck{ name: "edit-command", - binaryname: editCommand, + binaryName: editCommand, ifNotSet: checkResultWarning, ifNotExist: checkResultWarning, }, - &umaskCheck{}, + &argsCheck{ + name: "edit-args", + command: editCommand, + args: editArgs, + }, &binaryCheck{ name: "git-command", - binaryname: c.Git.Command, + binaryName: c.Git.Command, ifNotSet: checkResultWarning, ifNotExist: checkResultWarning, versionArgs: []string{"--version"}, @@ -183,21 +260,32 @@ func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { }, &binaryCheck{ name: "merge-command", - binaryname: c.Merge.Command, + binaryName: c.Merge.Command, ifNotSet: checkResultWarning, ifNotExist: checkResultWarning, }, + &binaryCheck{ + name: "shell-command", + binaryName: shellCommand, + ifNotSet: checkResultError, + ifNotExist: checkResultError, + }, + &argsCheck{ + name: "shell-args", + command: shellCommand, + args: shellArgs, + }, &binaryCheck{ name: "age-command", - binaryname: c.Age.Command, - versionArgs: []string{"-version"}, - versionRx: regexp.MustCompile(`v(\d+\.\d+\.\d+\S*)`), + binaryName: c.Age.Command, + versionArgs: []string{"--version"}, + versionRx: regexp.MustCompile(`(\d+\.\d+\.\d+\S*)`), ifNotSet: checkResultWarning, ifNotExist: checkResultInfo, }, &binaryCheck{ name: "gpg-command", - binaryname: c.GPG.Command, + binaryName: c.GPG.Command, versionArgs: []string{"--version"}, versionRx: regexp.MustCompile(`(?m)^gpg\s+\(.*?\)\s+(\d+\.\d+\.\d+)`), ifNotSet: checkResultWarning, @@ -205,7 +293,7 @@ func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { }, &binaryCheck{ name: "pinentry-command", - binaryname: c.PINEntry.Command, + binaryName: c.PINEntry.Command, versionArgs: []string{"--version"}, versionRx: regexp.MustCompile(`^\S+\s+\(pinentry\)\s+(\d+\.\d+\.\d+)`), ifNotSet: checkResultInfo, @@ -213,23 +301,48 @@ func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { }, &binaryCheck{ name: "1password-command", - binaryname: c.Onepassword.Command, + binaryName: c.Onepassword.Command, ifNotSet: checkResultWarning, ifNotExist: checkResultInfo, versionArgs: []string{"--version"}, - versionRx: regexp.MustCompile(`^(\d+\.\d+\.\d+)`), + versionRx: onepasswordVersionRx, + minVersion: &onepasswordMinVersion, }, &binaryCheck{ name: "bitwarden-command", - binaryname: c.Bitwarden.Command, + binaryName: c.Bitwarden.Command, + ifNotSet: checkResultWarning, + ifNotExist: checkResultInfo, + versionArgs: []string{"--version"}, + versionRx: regexp.MustCompile(`(?m)^(\d+\.\d+\.\d+)$`), + }, + &binaryCheck{ + name: "bitwarden-secrets-command", + binaryName: c.BitwardenSecrets.Command, + ifNotSet: checkResultWarning, + ifNotExist: checkResultInfo, + versionArgs: []string{"--version"}, + versionRx: regexp.MustCompile(`Bitwarden\s+Secrets\s+CLI\s+(\d+\.\d+\.\d+)`), + }, + &binaryCheck{ + name: "dashlane-command", + binaryName: c.Dashlane.Command, ifNotSet: checkResultWarning, ifNotExist: checkResultInfo, versionArgs: []string{"--version"}, versionRx: regexp.MustCompile(`^(\d+\.\d+\.\d+)`), }, + &binaryCheck{ + name: "doppler-command", + binaryName: c.Doppler.Command, + ifNotSet: checkResultWarning, + ifNotExist: checkResultInfo, + versionArgs: []string{"--version"}, + versionRx: regexp.MustCompile(`^v(\d+\.\d+\.\d+)`), + }, &binaryCheck{ name: "gopass-command", - binaryname: c.Gopass.Command, + binaryName: c.Gopass.Command, ifNotSet: checkResultWarning, ifNotExist: checkResultInfo, versionArgs: gopassVersionArgs, @@ -238,11 +351,12 @@ func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { }, &binaryCheck{ name: "keepassxc-command", - binaryname: c.Keepassxc.Command, + binaryName: c.Keepassxc.Command, ifNotSet: checkResultWarning, ifNotExist: checkResultInfo, versionArgs: []string{"--version"}, versionRx: regexp.MustCompile(`^(\d+\.\d+\.\d+)`), + minVersion: &keepassxcMinVersion, }, &fileCheck{ name: "keepassxc-db", @@ -250,9 +364,17 @@ func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { ifNotSet: checkResultInfo, ifNotExist: checkResultInfo, }, + &binaryCheck{ + name: "keeper-command", + binaryName: c.Keeper.Command, + ifNotSet: checkResultWarning, + ifNotExist: checkResultInfo, + versionArgs: []string{"version"}, + versionRx: regexp.MustCompile(`^Commander\s+Version:\s+(\d+\.\d+\.\d+)`), + }, &binaryCheck{ name: "lastpass-command", - binaryname: c.Lastpass.Command, + binaryName: c.Lastpass.Command, ifNotSet: checkResultWarning, ifNotExist: checkResultInfo, versionArgs: lastpassVersionArgs, @@ -261,23 +383,50 @@ func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { }, &binaryCheck{ name: "pass-command", - binaryname: c.Pass.Command, + binaryName: c.Pass.Command, ifNotSet: checkResultWarning, ifNotExist: checkResultInfo, versionArgs: []string{"version"}, versionRx: regexp.MustCompile(`(?m)=\s*v(\d+\.\d+\.\d+)`), }, + &binaryCheck{ + name: "passhole-command", + binaryName: c.Passhole.Command, + ifNotSet: checkResultWarning, + ifNotExist: checkResultInfo, + versionArgs: []string{"--version"}, + versionRx: regexp.MustCompile(`^(\d+\.\d+\.\d+)`), + minVersion: &passholeMinVersion, + }, + &binaryCheck{ + name: "rbw-command", + binaryName: c.RBW.Command, + ifNotSet: checkResultWarning, + ifNotExist: checkResultInfo, + versionArgs: []string{"--version"}, + versionRx: regexp.MustCompile(`^rbw\s+(\d+\.\d+\.\d+)`), + minVersion: &rbwMinVersion, + }, &binaryCheck{ name: "vault-command", - binaryname: c.Vault.Command, + binaryName: c.Vault.Command, ifNotSet: checkResultWarning, ifNotExist: checkResultInfo, versionArgs: []string{"version"}, versionRx: regexp.MustCompile(`^Vault\s+v(\d+\.\d+\.\d+)`), }, + &binaryCheck{ + name: "vlt-command", + binaryName: c.HCPVaultSecrets.Command, + ifNotSet: checkResultWarning, + ifNotExist: checkResultInfo, + versionArgs: []string{"version"}, + versionRx: regexp.MustCompile(`^(\d+\.\d+\.\d+)`), + minVersion: &vltMinVersion, + }, &binaryCheck{ name: "secret-command", - binaryname: c.Secret.Command, + binaryName: c.Secret.Command, ifNotSet: checkResultInfo, ifNotExist: checkResultInfo, }, @@ -288,7 +437,7 @@ func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { fmt.Fprint(resultWriter, "RESULT\tCHECK\tMESSAGE\n") for _, check := range checks { checkResult, message := check.Run(c.baseSystem, homeDirAbsPath) - if checkResult == checkResultSkipped { + if checkResult == checkResultOmitted { continue } // Conceal the user's actual home directory in the message as the @@ -303,25 +452,33 @@ func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error { resultWriter.Flush() if worstResult > checkResultWarning { - return ExitCodeError(1) + return chezmoi.ExitCodeError(1) } return nil } +func (c *argsCheck) Name() string { + return c.name +} + +func (c *argsCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { + return checkResultOK, shellQuoteCommand(c.command, c.args) +} + func (c *binaryCheck) Name() string { return c.name } func (c *binaryCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { - if c.binaryname == "" { + if c.binaryName == "" { return c.ifNotSet, "not set" } var pathAbsPath chezmoi.AbsPath - switch path, err := exec.LookPath(c.binaryname); { + switch path, err := chezmoi.LookPath(c.binaryName); { case errors.Is(err, exec.ErrNotFound): - return c.ifNotExist, fmt.Sprintf("%s not found in $PATH", c.binaryname) + return c.ifNotExist, c.binaryName + " not found in $PATH" case err != nil: return checkResultFailed, err.Error() default: @@ -335,9 +492,8 @@ func (c *binaryCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) return checkResultOK, fmt.Sprintf("found %s", pathAbsPath) } - //nolint:gosec - cmd := exec.Command(pathAbsPath.String(), c.versionArgs...) - output, err := chezmoilog.LogCmdCombinedOutput(cmd) + cmd := exec.Command(pathAbsPath.String(), c.versionArgs...) //nolint:gosec + output, err := chezmoilog.LogCmdCombinedOutput(slog.Default(), cmd) if err != nil { return checkResultFailed, err.Error() } @@ -368,16 +524,16 @@ func (c *configFileCheck) Name() string { } func (c *configFileCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { - filenameAbsPaths := make(map[chezmoi.AbsPath]struct{}) + filenameAbsPaths := chezmoiset.New[chezmoi.AbsPath]() for _, dir := range append([]string{c.bds.ConfigHome}, c.bds.ConfigDirs...) { configDirAbsPath, err := chezmoi.NewAbsPathFromExtPath(dir, homeDirAbsPath) if err != nil { return checkResultFailed, err.Error() } - for _, extension := range viper.SupportedExts { + for _, extension := range chezmoi.FormatExtensions { filenameAbsPath := configDirAbsPath.Join(c.basename, chezmoi.NewRelPath(c.basename.String()+"."+extension)) if _, err := system.Stat(filenameAbsPath); err == nil { - filenameAbsPaths[filenameAbsPath] = struct{}{} + filenameAbsPaths.Add(filenameAbsPath) } } } @@ -385,23 +541,30 @@ func (c *configFileCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsP case 0: return checkResultOK, "no config file found" case 1: - var filenameAbsPath chezmoi.AbsPath - for filenameAbsPath = range filenameAbsPaths { - } + filenameAbsPath := filenameAbsPaths.AnyElement() if filenameAbsPath != c.expected { return checkResultFailed, fmt.Sprintf("found %s, expected %s", filenameAbsPath, c.expected) } - if _, err := system.ReadFile(filenameAbsPath); err != nil { + config, err := newConfig() + if err != nil { + return checkResultError, err.Error() + } + if err := config.decodeConfigFile(filenameAbsPath, &config.ConfigFile); err != nil { return checkResultError, fmt.Sprintf("%s: %v", filenameAbsPath, err) } - return checkResultOK, filenameAbsPath.String() + fileInfo, err := system.Stat(filenameAbsPath) + if err != nil { + return checkResultError, fmt.Sprintf("%s: %v", filenameAbsPath, err) + } + message := fmt.Sprintf("%s, last modified %s", filenameAbsPath.String(), fileInfo.ModTime().Format(time.RFC3339)) + return checkResultOK, message default: filenameStrs := make([]string, 0, len(filenameAbsPaths)) for filenameAbsPath := range filenameAbsPaths { filenameStrs = append(filenameStrs, filenameAbsPath.String()) } sort.Strings(filenameStrs) - return checkResultWarning, fmt.Sprintf("%s: multiple config files", englishList(filenameStrs)) + return checkResultWarning, englishList(filenameStrs) + ": multiple config files" } } @@ -410,17 +573,58 @@ func (c *dirCheck) Name() string { } func (c *dirCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { - if _, err := system.ReadDir(c.dirname); err != nil { + dirEntries, err := system.ReadDir(c.dirname) + if err != nil { return checkResultError, err.Error() } - return checkResultOK, fmt.Sprintf("%s is a directory", c.dirname) + + gitStatus := gitStatusNotAWorkingCopy + for _, dirEntry := range dirEntries { + if dirEntry.Name() != ".git" { + continue + } + cmd := exec.Command( //nolint:gosec + "git", + "-C", + c.dirname.String(), + "status", + "--porcelain=v2", + ) + cmd.Stderr = os.Stderr + output, err := cmd.Output() + if err != nil { + gitStatus = gitStatusError + break + } + switch status, err := chezmoigit.ParseStatusPorcelainV2(output); { + case err != nil: + gitStatus = gitStatusError + case status.Empty(): + gitStatus = gitStatusClean + default: + gitStatus = gitStatusDirty + } + break + } + switch gitStatus { + case gitStatusNotAWorkingCopy: + return checkResultOK, fmt.Sprintf("%s is a directory", c.dirname) + case gitStatusClean: + return checkResultOK, fmt.Sprintf("%s is a git working tree (clean)", c.dirname) + case gitStatusDirty: + return checkResultWarning, fmt.Sprintf("%s is a git working tree (dirty)", c.dirname) + case gitStatusError: + return checkResultError, fmt.Sprintf("%s is a git working tree (error)", c.dirname) + default: + panic(fmt.Sprintf("%s: unknown git status", gitStatus)) + } } -func (c *executableCheck) Name() string { +func (executableCheck) Name() string { return "executable" } -func (c *executableCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { +func (executableCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { executable, err := os.Executable() if err != nil { return checkResultError, err.Error() @@ -451,13 +655,62 @@ func (c *fileCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) ( } } +func (goVersionCheck) Name() string { + return "go-version" +} + +func (goVersionCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { + return checkResultOK, fmt.Sprintf("%s (%s)", runtime.Version(), runtime.Compiler) +} + +func (c *latestVersionCheck) Name() string { + return "latest-version" +} + +func (c *latestVersionCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { + switch { + case !c.network: + return checkResultSkipped, "no network" + case c.httpClientErr != nil: + return checkResultFailed, c.httpClientErr.Error() + } + + ctx := context.Background() + + gitHubClient := chezmoi.NewGitHubClient(ctx, c.httpClient) + rr, _, err := gitHubClient.Repositories.GetLatestRelease(ctx, "twpayne", "chezmoi") + var rateLimitErr *github.RateLimitError + var abuseRateLimitErr *github.AbuseRateLimitError + switch { + case err == nil: + // Do nothing. + case errors.As(err, &rateLimitErr): + return checkResultFailed, "GitHub rate limit exceeded" + case errors.As(err, &abuseRateLimitErr): + return checkResultFailed, "GitHub abuse rate limit exceeded" + default: + return checkResultFailed, err.Error() + } + + version, err := semver.NewVersion(strings.TrimPrefix(rr.GetName(), "v")) + if err != nil { + return checkResultError, err.Error() + } + + checkResult := checkResultOK + if c.version.LessThan(*version) { + checkResult = checkResultWarning + } + return checkResult, "v" + version.String() +} + func (osArchCheck) Name() string { return "os-arch" } func (osArchCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { fields := []string{runtime.GOOS + "/" + runtime.GOARCH} - if osRelease, err := chezmoi.OSRelease(system); err == nil { + if osRelease, err := chezmoi.OSRelease(system.UnderlyingFS()); err == nil { if name, ok := osRelease["NAME"].(string); ok { if version, ok := osRelease["VERSION"].(string); ok { fields = append(fields, "("+name+" "+version+")") @@ -469,6 +722,14 @@ func (osArchCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (c return checkResultOK, strings.Join(fields, " ") } +func (omittedCheck) Name() string { + return "omitted" +} + +func (omittedCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { + return checkResultOmitted, "" +} + func (c *suspiciousEntriesCheck) Name() string { return "suspicious-entries" } @@ -480,7 +741,7 @@ func (c *suspiciousEntriesCheck) Run(system chezmoi.System, homeDirAbsPath chezm if err != nil { return err } - if chezmoi.SuspiciousSourceDirEntry(absPath.Base(), fileInfo) { + if chezmoi.SuspiciousSourceDirEntry(absPath.Base(), fileInfo, c.encryptedSuffixes) { suspiciousEntries = append(suspiciousEntries, absPath.String()) } return nil @@ -497,11 +758,11 @@ func (c *suspiciousEntriesCheck) Run(system chezmoi.System, homeDirAbsPath chezm return checkResultOK, "no suspicious entries" } -func (c *umaskCheck) Name() string { - return "umask" +func (upgradeMethodCheck) Name() string { + return "upgrade-method" } -func (c *upgradeMethodCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { +func (upgradeMethodCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { executable, err := os.Executable() if err != nil { return checkResultFailed, err.Error() @@ -511,15 +772,11 @@ func (c *upgradeMethodCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.A return checkResultFailed, err.Error() } if method == "" { - return checkResultSkipped, "" + return checkResultOmitted, "" } return checkResultOK, method } -func (c *upgradeMethodCheck) Name() string { - return "upgrade-method" -} - func (c *versionCheck) Name() string { return "version" } diff --git a/internal/cmd/doctorcmd_unix.go b/internal/cmd/doctorcmd_unix.go index 084ad95f2d1..e3d7e28d7a1 100644 --- a/internal/cmd/doctorcmd_unix.go +++ b/internal/cmd/doctorcmd_unix.go @@ -1,17 +1,32 @@ -//go:build !windows -// +build !windows +//go:build unix package cmd import ( + "bytes" "fmt" + "log/slog" + "os" + "os/exec" + "runtime" "golang.org/x/sys/unix" "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) -func (c *umaskCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { +type ( + systeminfoCheck struct{ omittedCheck } + umaskCheck struct{} + unameCheck struct{} +) + +func (umaskCheck) Name() string { + return "umask" +} + +func (umaskCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { umask := unix.Umask(0) unix.Umask(umask) result := checkResultOK @@ -20,3 +35,20 @@ func (c *umaskCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) } return result, fmt.Sprintf("%03o", umask) } + +func (unameCheck) Name() string { + return "uname" +} + +func (unameCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { + if runtime.GOOS == "windows" { + return checkResultOmitted, "" + } + cmd := exec.Command("uname", "-a") + cmd.Stderr = os.Stderr + data, err := chezmoilog.LogCmdOutput(slog.Default(), cmd) + if err != nil { + return checkResultFailed, err.Error() + } + return checkResultOK, string(bytes.TrimSpace(data)) +} diff --git a/internal/cmd/doctorcmd_windows.go b/internal/cmd/doctorcmd_windows.go index 92a52b84511..cffba14c199 100644 --- a/internal/cmd/doctorcmd_windows.go +++ b/internal/cmd/doctorcmd_windows.go @@ -1,7 +1,45 @@ package cmd -import "github.com/twpayne/chezmoi/v2/internal/chezmoi" +import ( + "bufio" + "bytes" + "fmt" + "log/slog" + "os/exec" + "strings" -func (c *umaskCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { - return checkResultSkipped, "" + "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +type ( + systeminfoCheck struct{} + umaskCheck struct{ omittedCheck } + unameCheck struct{ omittedCheck } +) + +func (systeminfoCheck) Name() string { + return "systeminfo" +} + +func (systeminfoCheck) Run(system chezmoi.System, homeDirAbsPath chezmoi.AbsPath) (checkResult, string) { + cmd := exec.Command("systeminfo") + data, err := chezmoilog.LogCmdOutput(slog.Default(), cmd) + if err != nil { + return checkResultFailed, err.Error() + } + + var osName, osVersion string + s := bufio.NewScanner(bytes.NewReader(data)) + for s.Scan() { + switch key, value, found := strings.Cut(s.Text(), ":"); { + case !found: + // Do nothing. + case key == "OS Name": + osName = strings.TrimSpace(value) + case key == "OS Version": + osVersion = strings.TrimSpace(value) + } + } + return checkResultOK, fmt.Sprintf("%s (%s)", osName, osVersion) } diff --git a/internal/cmd/dopplertemplatefuncs.go b/internal/cmd/dopplertemplatefuncs.go new file mode 100644 index 00000000000..4f99436dc33 --- /dev/null +++ b/internal/cmd/dopplertemplatefuncs.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "slices" + "strings" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +type dopplerConfig struct { + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + Project string `json:"project" mapstructure:"project" yaml:"project"` + Config string `json:"config" mapstructure:"config" yaml:"config"` + outputCache map[string][]byte +} + +func (c *Config) dopplerTemplateFunc(key string, additionalArgs ...string) any { + if len(additionalArgs) > 2 { + // Add one to the number of received arguments as the key + // is the first argument. + panic(fmt.Errorf("expected 1 to 3 arguments, got %d", len(additionalArgs)+1)) + } + + args := c.appendDopplerAdditionalArgs([]string{"secrets", "download", "--json", "--no-file"}, additionalArgs) + + data, err := c.dopplerOutput(args) + if err != nil { + panic(err) + } + var value map[string]any + if err := json.Unmarshal(data, &value); err != nil { + panic(err) + } + + secret, ok := value[key] + if !ok { + panic(fmt.Errorf("could not find requested secret: %s", key)) + } + + return secret +} + +func (c *Config) dopplerProjectJSONTemplateFunc(additionalArgs ...string) any { + if len(additionalArgs) > 2 { + panic(fmt.Errorf("expected 0 to 2 arguments, got %d", len(additionalArgs))) + } + args := c.appendDopplerAdditionalArgs([]string{"secrets", "download", "--json", "--no-file"}, additionalArgs) + + data, err := c.dopplerOutput(args) + if err != nil { + panic(err) + } + var value any + if err := json.Unmarshal(data, &value); err != nil { + panic(err) + } + return value +} + +func (c *Config) appendDopplerAdditionalArgs(args, additionalArgs []string) []string { + if len(additionalArgs) > 0 && additionalArgs[0] != "" { + args = append(args, "--project", additionalArgs[0]) + } else if c.Doppler.Project != "" { + args = append(args, "--project", c.Doppler.Project) + } + if len(additionalArgs) > 1 && additionalArgs[1] != "" { + args = append(args, "--config", additionalArgs[1]) + } else if c.Doppler.Config != "" { + args = append(args, "--config", c.Doppler.Config) + } + + return args +} + +func (c *Config) dopplerOutput(args []string) ([]byte, error) { + args = append(slices.Clone(c.Doppler.Args), args...) + key := strings.Join(args, "\x00") + if data, ok := c.Doppler.outputCache[key]; ok { + return data, nil + } + cmd := exec.Command(c.Doppler.Command, args...) //nolint:gosec + // Always run the doppler command in the destination path because doppler uses + // relative paths to find its .doppler.json config file. + cmd.Dir = c.DestDirAbsPath.String() + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) + if err != nil { + return nil, newCmdOutputError(cmd, output, err) + } + + if c.Doppler.outputCache == nil { + c.Doppler.outputCache = make(map[string][]byte) + } + c.Doppler.outputCache[key] = output + return output, nil +} diff --git a/internal/cmd/dumpcmd.go b/internal/cmd/dumpcmd.go index 507fa94a251..61fcf1711cb 100644 --- a/internal/cmd/dumpcmd.go +++ b/internal/cmd/dumpcmd.go @@ -7,31 +7,30 @@ import ( ) type dumpCmdConfig struct { - exclude *chezmoi.EntryTypeSet - format writeDataFormat - include *chezmoi.EntryTypeSet + filter *chezmoi.EntryTypeFilter init bool 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, - }, + Use: "dump [target]...", + Short: "Generate a dump of the target state", + Long: mustLongHelp("dump"), + Example: example("dump"), + ValidArgsFunction: c.targetValidArgs, + RunE: c.runDumpCmd, + Annotations: newAnnotations( + persistentStateModeReadMockWrite, + requiresSourceDirectory, + ), } - flags := dumpCmd.Flags() - flags.VarP(c.dump.exclude, "exclude", "x", "Exclude entry types") - flags.VarP(&c.dump.format, "format", "f", "Set output format") - flags.VarP(c.dump.include, "include", "i", "Include entry types") - flags.BoolVar(&c.dump.init, "init", c.update.init, "Recreate config file from template") - flags.BoolVarP(&c.dump.recursive, "recursive", "r", c.dump.recursive, "Recurse into subdirectories") + dumpCmd.Flags().VarP(c.dump.filter.Exclude, "exclude", "x", "Exclude entry types") + dumpCmd.Flags().VarP(&c.Format, "format", "f", "Output format") + dumpCmd.Flags().VarP(c.dump.filter.Include, "include", "i", "Include entry types") + dumpCmd.Flags().BoolVar(&c.dump.init, "init", c.dump.init, "Recreate config file from template") + dumpCmd.Flags().BoolVarP(&c.dump.recursive, "recursive", "r", c.dump.recursive, "Recurse into subdirectories") return dumpCmd } @@ -39,12 +38,13 @@ func (c *Config) newDumpCmd() *cobra.Command { func (c *Config) runDumpCmd(cmd *cobra.Command, args []string) error { dumpSystem := chezmoi.NewDumpSystem() if err := c.applyArgs(cmd.Context(), dumpSystem, chezmoi.EmptyAbsPath, args, applyArgsOptions{ - include: c.dump.include.Sub(c.dump.exclude), + cmd: cmd, + filter: c.dump.filter, init: c.dump.init, recursive: c.dump.recursive, umask: c.Umask, }); err != nil { return err } - return c.marshal(c.dump.format, dumpSystem.Data()) + return c.marshal(c.Format, dumpSystem.Data()) } diff --git a/internal/cmd/dumpconfigcmd.go b/internal/cmd/dumpconfigcmd.go new file mode 100644 index 00000000000..2198e95954b --- /dev/null +++ b/internal/cmd/dumpconfigcmd.go @@ -0,0 +1,23 @@ +package cmd + +import "github.com/spf13/cobra" + +func (c *Config) newDumpConfigCmd() *cobra.Command { + dumpConfigCmd := &cobra.Command{ + Use: "dump-config", + Short: "Dump the configuration values", + Long: mustLongHelp("dump-config"), + Example: example("dump-config"), + Args: cobra.NoArgs, + RunE: c.runDumpConfigCmd, + Annotations: newAnnotations(), + } + + dumpConfigCmd.Flags().VarP(&c.Format, "format", "f", "Output format") + + return dumpConfigCmd +} + +func (c *Config) runDumpConfigCmd(cmd *cobra.Command, args []string) error { + return c.marshal(c.Format, c) +} diff --git a/internal/cmd/editcmd.go b/internal/cmd/editcmd.go index 04bec6e6361..336fecb9901 100644 --- a/internal/cmd/editcmd.go +++ b/internal/cmd/editcmd.go @@ -1,60 +1,65 @@ package cmd import ( + "log/slog" "os" "runtime" "time" + "github.com/fsnotify/fsnotify" "github.com/spf13/cobra" "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) type editCmdConfig struct { - Command string `mapstructure:"command"` - Args []string `mapstructure:"args"` - Hardlink bool `mapstructure:"hardlink"` - MinDuration time.Duration `mapstructure:"minDuration"` - apply bool - exclude *chezmoi.EntryTypeSet - include *chezmoi.EntryTypeSet + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + Hardlink bool `json:"hardlink" mapstructure:"hardlink" yaml:"hardlink"` + MinDuration time.Duration `json:"minDuration" mapstructure:"minDuration" yaml:"minDuration"` + Watch bool `json:"watch" mapstructure:"watch" yaml:"watch"` + Apply bool `json:"apply" mapstructure:"apply" yaml:"apply"` + filter *chezmoi.EntryTypeFilter init bool } 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", - }, + Use: "edit targets...", + Short: "Edit the source state of a target", + Long: mustLongHelp("edit"), + Example: example("edit"), + ValidArgsFunction: c.targetValidArgs, + RunE: c.runEditCmd, + Annotations: newAnnotations( + modifiesDestinationDirectory, + modifiesSourceDirectory, + persistentStateModeReadWrite, + requiresSourceDirectory, + runsCommands, + ), } - flags := editCmd.Flags() - flags.BoolVarP(&c.Edit.apply, "apply", "a", c.Edit.apply, "Apply after editing") - flags.VarP(c.Edit.exclude, "exclude", "x", "Exclude entry types") - flags.BoolVar(&c.Edit.Hardlink, "hardlink", c.Edit.Hardlink, "Invoke editor with a hardlink to the source file") - flags.VarP(c.Edit.include, "include", "i", "Include entry types") - flags.BoolVar(&c.Edit.init, "init", c.update.init, "Recreate config file from template") + editCmd.Flags().BoolVarP(&c.Edit.Apply, "apply", "a", c.Edit.Apply, "Apply after editing") + editCmd.Flags().VarP(c.Edit.filter.Exclude, "exclude", "x", "Exclude entry types") + editCmd.Flags().BoolVar(&c.Edit.Hardlink, "hardlink", c.Edit.Hardlink, "Invoke editor with a hardlink to the source file") + editCmd.Flags().VarP(c.Edit.filter.Include, "include", "i", "Include entry types") + editCmd.Flags().BoolVar(&c.Edit.init, "init", c.Edit.init, "Recreate config file from template") + editCmd.Flags().BoolVar(&c.Edit.Watch, "watch", c.Edit.Watch, "Apply on save") return editCmd } -func (c *Config) runEditCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { +func (c *Config) runEditCmd(cmd *cobra.Command, args []string) error { if len(args) == 0 { if err := c.runEditor([]string{c.WorkingTreeAbsPath.String()}); err != nil { return err } - if c.Edit.apply { + if c.Edit.Apply { if err := c.applyArgs(cmd.Context(), c.destSystem, c.DestDirAbsPath, noArgs, applyArgsOptions{ - include: c.Edit.include.Sub(c.Edit.exclude), + cmd: cmd, + filter: c.Edit.filter, init: c.Edit.init, recursive: true, umask: c.Umask, @@ -66,7 +71,12 @@ func (c *Config) runEditCmd(cmd *cobra.Command, args []string, sourceState *chez return nil } - targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ + sourceState, err := c.newSourceState(cmd.Context(), cmd) + if err != nil { + return err + } + + targetRelPaths, err := c.targetRelPaths(sourceState, args, &targetRelPathsOptions{ mustBeInSourceState: true, }) if err != nil { @@ -79,7 +89,7 @@ func (c *Config) runEditCmd(cmd *cobra.Command, args []string, sourceState *chez decryptedAbsPath chezmoi.AbsPath } var transparentlyDecryptedFiles []transparentlyDecryptedFile -TARGETRELPATH: +TARGET_REL_PATH: for _, targetRelPath := range targetRelPaths { sourceStateEntry := sourceState.MustEntry(targetRelPath) sourceRelPath := sourceStateEntry.SourceRelPath() @@ -134,16 +144,14 @@ TARGETRELPATH: // Attempt to create the hard link. If this succeeds, continue to // the next target. Hardlinking will fail if the temporary directory // is on a different filesystem to the source directory, which is - // not the case for most users. - // - // FIXME create a temporary directory on the same filesystem as the - // source directory if needed. + // not the case for most users. The user can set the tempDir + // configuration variable if needed. if err := os.MkdirAll(hardlinkAbsPath.Dir().String(), 0o700); err != nil { return err } if err := c.baseSystem.Link(c.SourceDirAbsPath.Join(sourceRelPath.RelPath()), hardlinkAbsPath); err == nil { editorArgs = append(editorArgs, hardlinkAbsPath.String()) - continue TARGETRELPATH + continue TARGET_REL_PATH } // Otherwise, fall through to the default option of editing the @@ -155,31 +163,77 @@ TARGETRELPATH: } } - if err := c.runEditor(editorArgs); err != nil { - return err + postEditFunc := func() error { + for _, transparentlyDecryptedFile := range transparentlyDecryptedFiles { + contents, err := c.encryption.EncryptFile(transparentlyDecryptedFile.decryptedAbsPath) + if err != nil { + return err + } + if err := c.baseSystem.WriteFile(transparentlyDecryptedFile.sourceAbsPath, contents, 0o666&^c.Umask); err != nil { + return err + } + } + + if c.Edit.Apply || c.Edit.Watch { + // Reset the cached source state to ensure that we re-read any + // changed files. + // + // FIXME Be more precise in what we invalidate. Only the changed + // files need to be re-read, not the entire source state. + c.resetSourceState() + + if err := c.applyArgs(cmd.Context(), c.destSystem, c.DestDirAbsPath, args, applyArgsOptions{ + cmd: cmd, + filter: c.Edit.filter, + init: c.Edit.init, + recursive: true, + umask: c.Umask, + preApplyFunc: c.defaultPreApplyFunc, + }); err != nil { + return err + } + } + + return nil } - for _, transparentlyDecryptedFile := range transparentlyDecryptedFiles { - contents, err := c.encryption.EncryptFile(transparentlyDecryptedFile.decryptedAbsPath) + if c.Edit.Watch { + watcher, err := fsnotify.NewWatcher() if err != nil { return err } - if err := c.baseSystem.WriteFile(transparentlyDecryptedFile.sourceAbsPath, contents, 0o666); err != nil { - return err + defer watcher.Close() + + for _, editorArg := range editorArgs { + // FIXME watch directories recursively + if err := watcher.Add(editorArg); err != nil { + return err + } } + + go func() { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + c.logger.Debug("watcher.Events", slog.String("Name", event.Name), chezmoilog.Stringer("Op", event.Op)) + err := postEditFunc() + chezmoilog.InfoOrError(c.logger, "postEditFunc", err) + case _, ok := <-watcher.Errors: + if !ok { + return + } + chezmoilog.InfoOrError(c.logger, "watcher.Errors", err) + } + } + }() } - if c.Edit.apply { - if err := c.applyArgs(cmd.Context(), c.destSystem, c.DestDirAbsPath, args, applyArgsOptions{ - include: c.Edit.include, - init: c.Edit.init, - recursive: false, - umask: c.Umask, - preApplyFunc: c.defaultPreApplyFunc, - }); err != nil { - return err - } + if err := c.runEditor(editorArgs); err != nil { + return err } - return nil + return postEditFunc() } diff --git a/internal/cmd/editconfigcmd.go b/internal/cmd/editconfigcmd.go index e655819fe4f..d1cc9973685 100644 --- a/internal/cmd/editconfigcmd.go +++ b/internal/cmd/editconfigcmd.go @@ -12,16 +12,17 @@ func (c *Config) newEditConfigCmd() *cobra.Command { Example: example("edit-config"), Args: cobra.NoArgs, RunE: c.runEditConfigCmd, - Annotations: map[string]string{ - modifiesConfigFile: "true", - requiresConfigDirectory: "true", - runsCommands: "true", - }, + Annotations: newAnnotations( + doesNotRequireValidConfig, + modifiesConfigFile, + requiresConfigDirectory, + runsCommands, + ), } return editConfigCmd } func (c *Config) runEditConfigCmd(cmd *cobra.Command, args []string) error { - return c.runEditor([]string{c.configFileAbsPath.String()}) + return c.runEditor([]string{c.getConfigFileAbsPath().String()}) } diff --git a/internal/cmd/editconfigtemplatecmd.go b/internal/cmd/editconfigtemplatecmd.go new file mode 100644 index 00000000000..f7e109a73c7 --- /dev/null +++ b/internal/cmd/editconfigtemplatecmd.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "errors" + "io/fs" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +func (c *Config) newEditConfigTemplateCmd() *cobra.Command { + editConfigCmd := &cobra.Command{ + Use: "edit-config-template", + Short: "Edit the configuration file template", + Long: mustLongHelp("edit-config-template"), + Example: example("edit-config-template"), + Args: cobra.NoArgs, + RunE: c.makeRunEWithSourceState(c.runEditConfigTemplateCmd), + Annotations: newAnnotations( + doesNotRequireValidConfig, + modifiesSourceDirectory, + runsCommands, + ), + } + + return editConfigCmd +} + +func (c *Config) runEditConfigTemplateCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + var configTemplateAbsPath chezmoi.AbsPath + switch configTemplate, err := c.findConfigTemplate(); { + case err != nil: + return err + case configTemplate != nil: + configTemplateAbsPath = configTemplate.sourceAbsPath + default: + if err := chezmoi.MkdirAll(c.sourceSystem, c.sourceDirAbsPath, fs.ModePerm); err != nil && + !errors.Is(err, fs.ErrExist) { + return err + } + configFileBase := "." + c.getConfigFileAbsPath().Base() + ".tmpl" + configTemplateAbsPath = c.sourceDirAbsPath.JoinString(configFileBase) + switch data, err := c.baseSystem.ReadFile(c.getConfigFileAbsPath()); { + case errors.Is(err, fs.ErrNotExist): + // Do nothing. + case err != nil: + return err + default: + if err := c.sourceSystem.WriteFile(configTemplateAbsPath, data, 0o666&^c.Umask); err != nil { + return err + } + } + } + return c.runEditor([]string{configTemplateAbsPath.String()}) +} diff --git a/internal/cmd/ejsontemplatefuncs.go b/internal/cmd/ejsontemplatefuncs.go new file mode 100644 index 00000000000..52aeff96e07 --- /dev/null +++ b/internal/cmd/ejsontemplatefuncs.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "encoding/json" + + "github.com/Shopify/ejson" +) + +type ejsonConfig struct { + KeyDir string `json:"keyDir" mapstructure:"keyDir" yaml:"keyDir"` + Key string `json:"key" mapstructure:"key" yaml:"key"` + cache map[string]any +} + +func (c *Config) ejsonDecryptWithKeyTemplateFunc(filePath, key string) any { + if data, ok := c.Ejson.cache[filePath]; ok { + return data + } + + if c.Ejson.cache == nil { + c.Ejson.cache = make(map[string]any) + } + + decrypted, err := ejson.DecryptFile(filePath, c.Ejson.KeyDir, key) + if err != nil { + panic(err) + } + + var data any + if err := json.Unmarshal(decrypted, &data); err != nil { + panic(err) + } + + c.Ejson.cache[filePath] = data + return data +} + +func (c *Config) ejsonDecryptTemplateFunc(filePath string) any { + return c.ejsonDecryptWithKeyTemplateFunc(filePath, c.Ejson.Key) +} diff --git a/internal/cmd/encryptcmd.go b/internal/cmd/encryptcmd.go index 512d248bb62..2daaaf739f5 100644 --- a/internal/cmd/encryptcmd.go +++ b/internal/cmd/encryptcmd.go @@ -5,15 +5,16 @@ import ( ) func (c *Config) newEncryptCommand() *cobra.Command { - decryptCommand := &cobra.Command{ - Use: "encrypt [file...]", - Short: "Encrypt file or standard input", - Long: mustLongHelp("encrypt"), - Example: example("encrypt"), - RunE: c.runEncryptCmd, + encryptCmd := &cobra.Command{ + Use: "encrypt [file...]", + Short: "Encrypt file or standard input", + Long: mustLongHelp("encrypt"), + Example: example("encrypt"), + RunE: c.runEncryptCmd, + Annotations: newAnnotations(), } - return decryptCommand + return encryptCmd } func (c *Config) runEncryptCmd(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/encryptiontemplatefuncs.go b/internal/cmd/encryptiontemplatefuncs.go index 655bac43223..5f7dc8cbe71 100644 --- a/internal/cmd/encryptiontemplatefuncs.go +++ b/internal/cmd/encryptiontemplatefuncs.go @@ -3,8 +3,7 @@ package cmd func (c *Config) decryptTemplateFunc(ciphertext string) string { plaintextBytes, err := c.encryption.Decrypt([]byte(ciphertext)) if err != nil { - returnTemplateError(err) - return "" + panic(err) } return string(plaintextBytes) } @@ -12,8 +11,7 @@ func (c *Config) decryptTemplateFunc(ciphertext string) string { func (c *Config) encryptTemplateFunc(plaintext string) string { ciphertextBytes, err := c.encryption.Encrypt([]byte(plaintext)) if err != nil { - returnTemplateError(err) - return "" + panic(err) } return string(ciphertextBytes) } diff --git a/internal/cmd/errors.go b/internal/cmd/errors.go new file mode 100644 index 00000000000..c4e60b92e5a --- /dev/null +++ b/internal/cmd/errors.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "fmt" + "os/exec" +) + +type cmdOutputError struct { + path string + args []string + output []byte + err error +} + +func newCmdOutputError(cmd *exec.Cmd, output []byte, err error) *cmdOutputError { + return &cmdOutputError{ + path: cmd.Path, + args: cmd.Args, + output: output, + err: err, + } +} + +func (e *cmdOutputError) Error() string { + if len(e.output) == 0 { + return fmt.Sprintf("%s: %v", shellQuoteCommand(e.path, e.args[1:]), e.err) + } + return fmt.Sprintf("%s: %v\n%s", shellQuoteCommand(e.path, e.args[1:]), e.err, e.output) +} + +func (e *cmdOutputError) Unwrap() error { + return e.err +} + +type parseCmdOutputError struct { + command string + args []string + output []byte + err error +} + +func newParseCmdOutputError(command string, args []string, output []byte, err error) *parseCmdOutputError { + return &parseCmdOutputError{ + command: command, + args: args, + output: output, + err: err, + } +} + +func (e *parseCmdOutputError) Error() string { + return fmt.Sprintf("%s: %v\n%s", shellQuoteCommand(e.command, e.args), e.err, e.output) +} + +func (e *parseCmdOutputError) Unwrap() error { + return e.err +} diff --git a/internal/cmd/executetemplatecmd.go b/internal/cmd/executetemplatecmd.go index dc91fa9f1c9..2366f2477d4 100644 --- a/internal/cmd/executetemplatecmd.go +++ b/internal/cmd/executetemplatecmd.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "io" + "slices" "strconv" "strings" @@ -12,11 +13,14 @@ import ( ) type executeTemplateCmdConfig struct { - init bool - promptBool map[string]string - promptInt map[string]int - promptString map[string]string - stdinIsATTY bool + init bool + promptBool map[string]string + promptChoice map[string]string + promptInt map[string]int + promptString map[string]string + stdinIsATTY bool + templateOptions chezmoi.TemplateOptions + withStdin bool } func (c *Config) newExecuteTemplateCmd() *cobra.Command { @@ -26,91 +30,212 @@ func (c *Config) newExecuteTemplateCmd() *cobra.Command { Long: mustLongHelp("execute-template"), Example: example("execute-template"), RunE: c.runExecuteTemplateCmd, + Annotations: newAnnotations( + persistentStateModeReadWrite, + ), } - 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") //nolint:lll - flags.StringToIntVar(&c.executeTemplate.promptInt, "promptInt", c.executeTemplate.promptInt, "Simulate promptInt") - flags.StringToStringVarP(&c.executeTemplate.promptString, "promptString", "p", c.executeTemplate.promptString, "Simulate promptString") //nolint:lll - flags.BoolVar(&c.executeTemplate.stdinIsATTY, "stdinisatty", c.executeTemplate.stdinIsATTY, "Simulate stdinIsATTY") + executeTemplateCmd.Flags().BoolVarP(&c.executeTemplate.init, "init", "i", c.executeTemplate.init, "Simulate chezmoi init") + executeTemplateCmd.Flags(). + StringToStringVar(&c.executeTemplate.promptBool, "promptBool", c.executeTemplate.promptBool, "Simulate promptBool") + executeTemplateCmd.Flags(). + StringToStringVar(&c.executeTemplate.promptChoice, "promptChoice", c.executeTemplate.promptChoice, "Simulate promptChoice") + executeTemplateCmd.Flags(). + StringToIntVar(&c.executeTemplate.promptInt, "promptInt", c.executeTemplate.promptInt, "Simulate promptInt") + executeTemplateCmd.Flags(). + StringToStringVarP(&c.executeTemplate.promptString, "promptString", "p", c.executeTemplate.promptString, "Simulate promptString") + executeTemplateCmd.Flags(). + BoolVar(&c.executeTemplate.stdinIsATTY, "stdinisatty", c.executeTemplate.stdinIsATTY, "Simulate stdinIsATTY") + executeTemplateCmd.Flags(). + StringVar(&c.executeTemplate.templateOptions.LeftDelimiter, "left-delimiter", c.executeTemplate.templateOptions.LeftDelimiter, "Set left template delimiter") + executeTemplateCmd.Flags(). + StringVar(&c.executeTemplate.templateOptions.RightDelimiter, "right-delimiter", c.executeTemplate.templateOptions.RightDelimiter, "Set right template delimiter") + executeTemplateCmd.Flags(). + BoolVar(&c.executeTemplate.withStdin, "with-stdin", c.executeTemplate.withStdin, "Set .chezmoi.stdin to the contents of the standard input") return executeTemplateCmd } func (c *Config) runExecuteTemplateCmd(cmd *cobra.Command, args []string) error { - var options []chezmoi.SourceStateOption + options := []chezmoi.SourceStateOption{ + chezmoi.WithTemplateDataOnly(true), + chezmoi.WithReadTemplates(!c.executeTemplate.init), + } if c.executeTemplate.init { options = append(options, chezmoi.WithReadTemplateData(false)) } - sourceState, err := c.newSourceState(cmd.Context(), options...) + if c.executeTemplate.withStdin && len(args) > 0 { + stdin, err := io.ReadAll(c.stdin) + if err != nil { + return err + } + options = append(options, chezmoi.WithPriorityTemplateData(map[string]any{ + "chezmoi": map[string]any{ + "stdin": string(stdin), + }, + })) + } + sourceState, err := c.newSourceState(cmd.Context(), cmd, options...) if err != nil { return err } promptBool := make(map[string]bool) for key, valueStr := range c.executeTemplate.promptBool { - value, err := parseBool(valueStr) + value, err := chezmoi.ParseBool(valueStr) if err != nil { return err } promptBool[key] = value } if c.executeTemplate.init { - chezmoi.RecursiveMerge(c.templateFuncs, map[string]interface{}{ - "promptBool": func(prompt string, args ...bool) bool { - switch len(args) { - case 0: - return promptBool[prompt] - case 1: - if value, ok := promptBool[prompt]; ok { - return value - } - return args[0] - default: - err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) - returnTemplateError(err) - return false + promptBoolInitTemplateFunc := func(prompt string, args ...bool) bool { + switch len(args) { + case 0: + return promptBool[prompt] + case 1: + if value, ok := promptBool[prompt]; ok { + return value } - }, - "promptInt": func(prompt string, args ...int) int { - switch len(args) { - case 0: - return c.executeTemplate.promptInt[prompt] - case 1: - if value, ok := c.executeTemplate.promptInt[prompt]; ok { - return value - } - return args[0] - default: - err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) - returnTemplateError(err) - return 0 + return args[0] + default: + err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + panic(err) + } + } + + promptBoolOnceInitTemplateFunc := func(m map[string]any, path any, field string, args ...bool) bool { + nestedMap, lastKey, err := nestedMapAtPath(m, path) + if err != nil { + panic(err) + } + if value, ok := nestedMap[lastKey]; ok { + if boolValue, ok := value.(bool); ok { + return boolValue } - }, - "promptString": func(prompt string, args ...string) string { - switch len(args) { - case 0: - if value, ok := c.executeTemplate.promptString[prompt]; ok { - return value + } + return promptBoolInitTemplateFunc(field, args...) + } + + promptChoiceInitTemplateFunc := func(prompt string, choices any, args ...string) string { + choiceStrs, err := anyToStringSlice(choices) + if err != nil { + panic(err) + } + switch len(args) { + case 0: + if value, ok := c.executeTemplate.promptChoice[prompt]; ok { + if !slices.Contains(choiceStrs, value) { + panic(fmt.Errorf("%s: invalid choice", value)) } - return prompt - case 1: - if value, ok := c.executeTemplate.promptString[prompt]; ok { - return value + return value + } + return prompt + case 1: + if value, ok := c.executeTemplate.promptChoice[prompt]; ok { + if !slices.Contains(choiceStrs, value) { + panic(fmt.Errorf("%s: invalid choice", value)) } - return args[0] - default: - err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) - returnTemplateError(err) - return "" + return value } - }, - "stdinIsATTY": func() bool { - return c.executeTemplate.stdinIsATTY - }, - "writeToStdout": c.writeToStdout, - }) + return args[0] + default: + err := fmt.Errorf("want 2 or 3 arguments, got %d", len(args)+1) + panic(err) + } + } + + promptChoiceOnceInitTemplateFunc := func(m map[string]any, path any, prompt string, choices []any, args ...string) string { + nestedMap, lastKey, err := nestedMapAtPath(m, path) + if err != nil { + panic(err) + } + if value, ok := nestedMap[lastKey]; ok { + if stringValue, ok := value.(string); ok { + return stringValue + } + } + return promptChoiceInitTemplateFunc(prompt, choices, args...) + } + + promptIntInitTemplateFunc := func(prompt string, args ...int64) int64 { + switch len(args) { + case 0: + return int64(c.executeTemplate.promptInt[prompt]) + case 1: + if value, ok := c.executeTemplate.promptInt[prompt]; ok { + return int64(value) + } + return args[0] + default: + err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + panic(err) + } + } + + promptIntOnceInitTemplateFunc := func(m map[string]any, path any, prompt string, args ...int64) int64 { + nestedMap, lastKey, err := nestedMapAtPath(m, path) + if err != nil { + panic(err) + } + if value, ok := nestedMap[lastKey]; ok { + if intValue, ok := value.(int64); ok { + return intValue + } + } + return promptIntInitTemplateFunc(prompt, args...) + } + + promptStringInitTemplateFunc := func(prompt string, args ...string) string { + switch len(args) { + case 0: + if value, ok := c.executeTemplate.promptString[prompt]; ok { + return value + } + return prompt + case 1: + if value, ok := c.executeTemplate.promptString[prompt]; ok { + return value + } + return args[0] + default: + err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + panic(err) + } + } + + promptStringOnceInitTemplateFunc := func(m map[string]any, path any, prompt string, args ...string) string { + nestedMap, lastKey, err := nestedMapAtPath(m, path) + if err != nil { + panic(err) + } + if value, ok := nestedMap[lastKey]; ok { + if stringValue, ok := value.(string); ok { + return stringValue + } + } + return promptStringInitTemplateFunc(prompt, args...) + } + + stdinIsATTYInitTemplateFunc := func() bool { + return c.executeTemplate.stdinIsATTY + } + + initTemplateFuncs := map[string]any{ + "exit": c.exitInitTemplateFunc, + "promptBool": promptBoolInitTemplateFunc, + "promptBoolOnce": promptBoolOnceInitTemplateFunc, + "promptChoice": promptChoiceInitTemplateFunc, + "promptChoiceOnce": promptChoiceOnceInitTemplateFunc, + "promptInt": promptIntInitTemplateFunc, + "promptIntOnce": promptIntOnceInitTemplateFunc, + "promptString": promptStringInitTemplateFunc, + "promptStringOnce": promptStringOnceInitTemplateFunc, + "stdinIsATTY": stdinIsATTYInitTemplateFunc, + "writeToStdout": c.writeToStdout, + } + + chezmoi.RecursiveMerge(c.templateFuncs, initTemplateFuncs) } if len(args) == 0 { @@ -118,7 +243,11 @@ func (c *Config) runExecuteTemplateCmd(cmd *cobra.Command, args []string) error if err != nil { return err } - output, err := sourceState.ExecuteTemplateData("stdin", data) + output, err := sourceState.ExecuteTemplateData(chezmoi.ExecuteTemplateDataOptions{ + Name: "stdin", + Data: data, + TemplateOptions: c.executeTemplate.templateOptions, + }) if err != nil { return err } @@ -127,7 +256,11 @@ func (c *Config) runExecuteTemplateCmd(cmd *cobra.Command, args []string) error output := strings.Builder{} for i, arg := range args { - result, err := sourceState.ExecuteTemplateData("arg"+strconv.Itoa(i+1), []byte(arg)) + result, err := sourceState.ExecuteTemplateData(chezmoi.ExecuteTemplateDataOptions{ + Name: "arg" + strconv.Itoa(i+1), + Data: []byte(arg), + TemplateOptions: c.executeTemplate.templateOptions, + }) if err != nil { return err } diff --git a/internal/cmd/forgetcmd.go b/internal/cmd/forgetcmd.go index b78a2d1e320..7ce64f4e9a8 100644 --- a/internal/cmd/forgetcmd.go +++ b/internal/cmd/forgetcmd.go @@ -10,32 +10,49 @@ import ( 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", - persistentStateMode: persistentStateModeReadWrite, - }, + Use: "forget target...", + Aliases: []string{"unmanage"}, + Short: "Remove a target from the source state", + Long: mustLongHelp("forget"), + Example: example("forget"), + ValidArgsFunction: c.targetValidArgs, + Args: cobra.MinimumNArgs(1), + RunE: c.makeRunEWithSourceState(c.runForgetCmd), + Annotations: newAnnotations( + modifiesSourceDirectory, + persistentStateModeReadWrite, + ), } return forgetCmd } func (c *Config) runForgetCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { - targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ - mustBeInSourceState: true, - }) + targetRelPaths, err := c.targetRelPaths(sourceState, args, nil) if err != nil { return err } +TARGET_REL_PATH: for _, targetRelPath := range targetRelPaths { - sourceAbsPath := c.SourceDirAbsPath.Join(sourceState.MustEntry(targetRelPath).SourceRelPath().RelPath()) + sourceStateEntry := sourceState.MustEntry(targetRelPath) + + // Skip source state entries that are not regular entries. These are + // removes or externals, which we cannot handle. + switch sourceStateOrigin := sourceStateEntry.Origin(); sourceStateOrigin.(type) { + case chezmoi.SourceStateOriginAbsPath: + // OK, keep going. + case chezmoi.SourceStateOriginRemove: + c.errorf("warning: %s: cannot forget entry from remove\n", targetRelPath) + continue TARGET_REL_PATH + case *chezmoi.External: + c.errorf("warning: %s: cannot forget entry from external %s\n", targetRelPath, sourceStateOrigin.OriginString()) + continue TARGET_REL_PATH + default: + panic(fmt.Sprintf("%s: %T: unknown source state origin type", targetRelPath, sourceStateOrigin)) + } + + sourceAbsPath := c.SourceDirAbsPath.Join(sourceStateEntry.SourceRelPath().RelPath()) if !c.force { choice, err := c.promptChoice(fmt.Sprintf("Remove %s", sourceAbsPath), choicesYesNoAllQuit) if err != nil { diff --git a/internal/cmd/generatecmd.go b/internal/cmd/generatecmd.go new file mode 100644 index 00000000000..ed6e97b10ca --- /dev/null +++ b/internal/cmd/generatecmd.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/v2/assets/templates" + "github.com/twpayne/chezmoi/v2/internal/chezmoigit" +) + +func (c *Config) newGenerateCmd() *cobra.Command { + generateCmd := &cobra.Command{ + Use: "generate file", + Short: "Generate a file for use with chezmoi", + Long: mustLongHelp("generate"), + Example: example("generate"), + Args: cobra.ExactArgs(1), + ValidArgs: []string{"install.sh"}, + RunE: c.runGenerateCmd, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), + } + + return generateCmd +} + +func (c *Config) runGenerateCmd(cmd *cobra.Command, args []string) error { + builder := strings.Builder{} + builder.Grow(16384) + switch args[0] { + case "git-commit-message": + output, err := c.cmdOutput(c.WorkingTreeAbsPath, c.Git.Command, []string{"status", "--porcelain=v2"}) + if err != nil { + return err + } + status, err := chezmoigit.ParseStatusPorcelainV2(output) + if err != nil { + return err + } + data, err := c.gitCommitMessage(cmd, status) + if err != nil { + return err + } + if _, err := builder.Write(data); err != nil { + return err + } + case "install.sh": + if _, err := builder.Write(templates.InstallSH); err != nil { + return err + } + default: + return fmt.Errorf("%s: unsupported file", args[0]) + } + return c.writeOutputString(builder.String()) +} diff --git a/internal/cmd/gitcmd.go b/internal/cmd/gitcmd.go index 1f74d00f5b1..60ab55dd484 100644 --- a/internal/cmd/gitcmd.go +++ b/internal/cmd/gitcmd.go @@ -5,10 +5,12 @@ import ( ) type gitCmdConfig struct { - Command string `mapstructure:"command"` - AutoAdd bool `mapstructure:"autoadd"` - AutoCommit bool `mapstructure:"autocommit"` - AutoPush bool `mapstructure:"autopush"` + Command string `json:"command" mapstructure:"command" yaml:"command"` + AutoAdd bool `json:"autoadd" mapstructure:"autoadd" yaml:"autoadd"` + AutoCommit bool `json:"autocommit" mapstructure:"autocommit" yaml:"autocommit"` + AutoPush bool `json:"autopush" mapstructure:"autopush" yaml:"autopush"` + CommitMessageTemplate string `json:"commitMessageTemplate" mapstructure:"commitMessageTemplate" yaml:"commitMessageTemplate"` + CommitMessageTemplateFile string `json:"commitMessageTemplateFile" mapstructure:"commitMessageTemplateFile" yaml:"commitMessageTemplateFile"` } func (c *Config) newGitCmd() *cobra.Command { @@ -18,11 +20,11 @@ func (c *Config) newGitCmd() *cobra.Command { Long: mustLongHelp("git"), Example: example("git"), RunE: c.runGitCmd, - Annotations: map[string]string{ - requiresSourceDirectory: "true", - requiresWorkingTree: "true", - runsCommands: "true", - }, + Annotations: newAnnotations( + createSourceDirectoryIfNeeded, + requiresWorkingTree, + runsCommands, + ), } return gitCmd diff --git a/internal/cmd/githubtemplatefuncs.go b/internal/cmd/githubtemplatefuncs.go index 36c31541404..6f918987282 100644 --- a/internal/cmd/githubtemplatefuncs.go +++ b/internal/cmd/githubtemplatefuncs.go @@ -3,14 +3,53 @@ package cmd import ( "context" "fmt" + "path" "strings" + "time" - "github.com/google/go-github/v40/github" + "github.com/google/go-github/v63/github" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +type gitHubConfig struct { + RefreshPeriod time.Duration `json:"refreshPeriod" mapstructure:"refreshPeriod" yaml:"refreshPeriod"` +} + +type gitHubKeysState struct { + RequestedAt time.Time `json:"requestedAt" yaml:"requestedAt"` + Keys []*github.Key `json:"keys" yaml:"keys"` +} + +type gitHubLatestReleaseState struct { + RequestedAt time.Time `json:"requestedAt" yaml:"requestedAt"` + Release *github.RepositoryRelease `json:"release" yaml:"release"` +} + +type gitHubReleasesState struct { + RequestedAt time.Time `json:"requestedAt" yaml:"requestedAt"` + Releases []*github.RepositoryRelease `json:"releases" yaml:"releases"` +} + +type gitHubTagsState struct { + RequestedAt time.Time `json:"requestedAt" yaml:"requestedAt"` + Tags []*github.RepositoryTag `json:"tags" yaml:"tags"` +} + +var ( + gitHubKeysStateBucket = []byte("gitHubLatestKeysState") + gitHubLatestReleaseStateBucket = []byte("gitHubLatestReleaseState") + gitHubReleasesStateBucket = []byte("gitHubReleasesState") + gitHubTagsStateBucket = []byte("gitHubTagsState") ) type gitHubData struct { + client *github.Client + clientErr error keysCache map[string][]*github.Key latestReleaseCache map[string]map[string]*github.RepositoryRelease + releasesCache map[string]map[string][]*github.RepositoryRelease + tagsCache map[string]map[string][]*github.RepositoryTag } func (c *Config) gitHubKeysTemplateFunc(user string) []*github.Key { @@ -18,15 +57,25 @@ func (c *Config) gitHubKeysTemplateFunc(user string) []*github.Key { return keys } + now := time.Now() + gitHubKeysKey := []byte(user) + if c.GitHub.RefreshPeriod != 0 { + var gitHubKeysValue gitHubKeysState + switch ok, err := chezmoi.PersistentStateGet(c.persistentState, gitHubKeysStateBucket, gitHubKeysKey, &gitHubKeysValue); { + case err != nil: + panic(err) + case ok && now.Before(gitHubKeysValue.RequestedAt.Add(c.GitHub.RefreshPeriod)): + return gitHubKeysValue.Keys + } + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() - httpClient, err := c.getHTTPClient() + gitHubClient, err := c.getGitHubClient(ctx) if err != nil { - returnTemplateError(err) - return nil + panic(err) } - gitHubClient := newGitHubClient(ctx, httpClient) var allKeys []*github.Key opts := &github.ListOptions{ @@ -35,8 +84,7 @@ func (c *Config) gitHubKeysTemplateFunc(user string) []*github.Key { for { keys, resp, err := gitHubClient.Users.ListKeys(ctx, user, opts) if err != nil { - returnTemplateError(err) - return nil + panic(err) } allKeys = append(allKeys, keys...) if resp.NextPage == 0 { @@ -45,53 +93,248 @@ func (c *Config) gitHubKeysTemplateFunc(user string) []*github.Key { opts.Page = resp.NextPage } + if err := chezmoi.PersistentStateSet(c.persistentState, gitHubKeysStateBucket, gitHubKeysKey, &gitHubKeysState{ + RequestedAt: now, + Keys: allKeys, + }); err != nil { + panic(err) + } + if c.gitHub.keysCache == nil { c.gitHub.keysCache = make(map[string][]*github.Key) } c.gitHub.keysCache[user] = allKeys + return allKeys } -func (c *Config) gitHubLatestReleaseTemplateFunc(userRepo string) *github.RepositoryRelease { - user, repo := parseGitHubUserRepo(userRepo) +func (c *Config) gitHubLatestReleaseAssetURLTemplateFunc(ownerRepo, pattern string) string { + release, err := c.gitHubLatestRelease(ownerRepo) + if err != nil { + panic(err) + } + for _, asset := range release.Assets { + if asset.Name == nil { + continue + } + switch ok, err := path.Match(pattern, *asset.Name); { + case err != nil: + panic(err) + case ok: + return *asset.BrowserDownloadURL + } + } + return "" +} + +func (c *Config) gitHubLatestRelease(ownerRepo string) (*github.RepositoryRelease, error) { + owner, repo, err := gitHubSplitOwnerRepo(ownerRepo) + if err != nil { + return nil, err + } - if release := c.gitHub.latestReleaseCache[user][repo]; release != nil { - return release + if release := c.gitHub.latestReleaseCache[owner][repo]; release != nil { + return release, nil + } + + now := time.Now() + gitHubLatestReleaseKey := []byte(owner + "/" + repo) + if c.GitHub.RefreshPeriod != 0 { + var gitHubLatestReleaseStateValue gitHubLatestReleaseState + switch ok, err := chezmoi.PersistentStateGet(c.persistentState, gitHubLatestReleaseStateBucket, gitHubLatestReleaseKey, &gitHubLatestReleaseStateValue); { + case err != nil: + return nil, err + case ok && now.Before(gitHubLatestReleaseStateValue.RequestedAt.Add(c.GitHub.RefreshPeriod)): + return gitHubLatestReleaseStateValue.Release, nil + } } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - httpClient, err := c.getHTTPClient() + gitHubClient, err := c.getGitHubClient(ctx) if err != nil { - returnTemplateError(err) - return nil + return nil, err } - gitHubClient := newGitHubClient(ctx, httpClient) - release, _, err := gitHubClient.Repositories.GetLatestRelease(ctx, user, repo) + release, _, err := gitHubClient.Repositories.GetLatestRelease(ctx, owner, repo) if err != nil { - returnTemplateError(err) - return nil + return nil, err + } + + if err := chezmoi.PersistentStateSet(c.persistentState, gitHubLatestReleaseStateBucket, gitHubLatestReleaseKey, &gitHubLatestReleaseState{ + RequestedAt: now, + Release: release, + }); err != nil { + return nil, err } if c.gitHub.latestReleaseCache == nil { c.gitHub.latestReleaseCache = make(map[string]map[string]*github.RepositoryRelease) } - if c.gitHub.latestReleaseCache[user] == nil { - c.gitHub.latestReleaseCache[user] = make(map[string]*github.RepositoryRelease) + if c.gitHub.latestReleaseCache[owner] == nil { + c.gitHub.latestReleaseCache[owner] = make(map[string]*github.RepositoryRelease) } - c.gitHub.latestReleaseCache[user][repo] = release + c.gitHub.latestReleaseCache[owner][repo] = release + + return release, nil +} +func (c *Config) gitHubLatestReleaseTemplateFunc(ownerRepo string) *github.RepositoryRelease { + release, err := c.gitHubLatestRelease(ownerRepo) + if err != nil { + panic(err) + } return release } -func parseGitHubUserRepo(userRepo string) (string, string) { - fields := strings.SplitN(userRepo, "/", 2) - if len(fields) != 2 || fields[0] == "" || fields[1] == "" { - returnTemplateError(fmt.Errorf("%s: not a user/repo", userRepo)) - return "", "" +func (c *Config) gitHubLatestTagTemplateFunc(ownerRepo string) *github.RepositoryTag { + tags, err := c.getGitHubTags(ownerRepo) + if err != nil { + panic(err) + } + + if len(tags) > 0 { + return tags[0] + } + + return nil +} + +func (c *Config) gitHubReleasesTemplateFunc(ownerRepo string) []*github.RepositoryRelease { + owner, repo, err := gitHubSplitOwnerRepo(ownerRepo) + if err != nil { + panic(err) + } + + if releases := c.gitHub.releasesCache[owner][repo]; releases != nil { + return releases + } + + now := time.Now() + gitHubReleasesKey := []byte(owner + "/" + repo) + if c.GitHub.RefreshPeriod != 0 { + var gitHubReleasesStateValue gitHubReleasesState + switch ok, err := chezmoi.PersistentStateGet(c.persistentState, gitHubReleasesStateBucket, gitHubReleasesKey, &gitHubReleasesStateValue); { + case err != nil: + panic(err) + case ok && now.Before(gitHubReleasesStateValue.RequestedAt.Add(c.GitHub.RefreshPeriod)): + return gitHubReleasesStateValue.Releases + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gitHubClient, err := c.getGitHubClient(ctx) + if err != nil { + panic(err) + } + + releases, _, err := gitHubClient.Repositories.ListReleases(ctx, owner, repo, nil) + if err != nil { + panic(err) + } + + if err := chezmoi.PersistentStateSet(c.persistentState, gitHubReleasesStateBucket, gitHubReleasesKey, &gitHubReleasesState{ + RequestedAt: now, + Releases: releases, + }); err != nil { + panic(err) + } + + if c.gitHub.releasesCache == nil { + c.gitHub.releasesCache = make(map[string]map[string][]*github.RepositoryRelease) + } + if c.gitHub.releasesCache[owner] == nil { + c.gitHub.releasesCache[owner] = make(map[string][]*github.RepositoryRelease) + } + c.gitHub.releasesCache[owner][repo] = releases + + return releases +} + +func (c *Config) gitHubTagsTemplateFunc(ownerRepo string) []*github.RepositoryTag { + tags, err := c.getGitHubTags(ownerRepo) + if err != nil { + panic(err) + } + + return tags +} + +func (c *Config) getGitHubTags(ownerRepo string) ([]*github.RepositoryTag, error) { + owner, repo, err := gitHubSplitOwnerRepo(ownerRepo) + if err != nil { + return nil, err + } + + if tags := c.gitHub.tagsCache[owner][repo]; tags != nil { + return tags, nil + } + + now := time.Now() + gitHubTagsKey := []byte(owner + "/" + repo) + if c.GitHub.RefreshPeriod != 0 { + var gitHubTagsStateValue gitHubTagsState + switch ok, err := chezmoi.PersistentStateGet(c.persistentState, gitHubTagsStateBucket, gitHubTagsKey, &gitHubTagsStateValue); { + case err != nil: + return nil, err + case ok && now.Before(gitHubTagsStateValue.RequestedAt.Add(c.GitHub.RefreshPeriod)): + return gitHubTagsStateValue.Tags, nil + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + gitHubClient, err := c.getGitHubClient(ctx) + if err != nil { + return nil, err + } + + tags, _, err := gitHubClient.Repositories.ListTags(ctx, owner, repo, nil) + if err != nil { + return nil, err + } + + if err := chezmoi.PersistentStateSet(c.persistentState, gitHubTagsStateBucket, gitHubTagsKey, &gitHubTagsState{ + RequestedAt: now, + Tags: tags, + }); err != nil { + return nil, err + } + + if c.gitHub.tagsCache == nil { + c.gitHub.tagsCache = make(map[string]map[string][]*github.RepositoryTag) + } + if c.gitHub.tagsCache[owner] == nil { + c.gitHub.tagsCache[owner] = make(map[string][]*github.RepositoryTag) + } + c.gitHub.tagsCache[owner][repo] = tags + + return tags, nil +} + +func (c *Config) getGitHubClient(ctx context.Context) (*github.Client, error) { + if c.gitHub.client != nil || c.gitHub.clientErr != nil { + return c.gitHub.client, c.gitHub.clientErr + } + + httpClient, err := c.getHTTPClient() + if err != nil { + c.gitHub.clientErr = err + return nil, err + } + + c.gitHub.client = chezmoi.NewGitHubClient(ctx, httpClient) + return c.gitHub.client, nil +} + +func gitHubSplitOwnerRepo(ownerRepo string) (string, string, error) { + owner, repo, ok := strings.Cut(ownerRepo, "/") + if !ok { + return "", "", fmt.Errorf("%s: not an owner/repo", ownerRepo) } - user, repo := fields[0], fields[1] - return user, repo + return owner, repo, nil } diff --git a/internal/cmd/gopasstemplatefuncs.go b/internal/cmd/gopasstemplatefuncs.go index 366ad883b27..0af78a22aaa 100644 --- a/internal/cmd/gopasstemplatefuncs.go +++ b/internal/cmd/gopasstemplatefuncs.go @@ -2,11 +2,13 @@ package cmd import ( "bytes" - "fmt" + "os" "os/exec" "regexp" "github.com/coreos/go-semver/semver" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) var ( @@ -19,61 +21,12 @@ var ( ) type gopassConfig struct { - Command string - versionOK bool - cache map[string]string - rawCache map[string][]byte -} - -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) gopassRawTemplateFunc(id string) string { - if !c.Gopass.versionOK { - if err := c.gopassVersionCheck(); err != nil { - returnTemplateError(err) - return "" - } - c.Gopass.versionOK = true - } - - if output, ok := c.Gopass.rawCache[id]; ok { - return string(output) - } - - args := []string{"show", "--noparsing", id} - output, err := c.gopassOutput(args...) - if err != nil { - returnTemplateError(fmt.Errorf("%s: %w", shellQuoteCommand(c.Gopass.Command, args), err)) - return "" - } - - if c.Gopass.rawCache == nil { - c.Gopass.rawCache = make(map[string][]byte) - } - c.Gopass.rawCache[id] = output - - return string(output) + Command string `json:"command" mapstructure:"command" yaml:"command"` + cache map[string]string + rawCache map[string][]byte } 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 password, ok := c.Gopass.cache[id]; ok { return password } @@ -81,16 +34,11 @@ func (c *Config) gopassTemplateFunc(id string) string { args := []string{"show", "--password", id} output, err := c.gopassOutput(args...) if err != nil { - returnTemplateError(fmt.Errorf("%s: %w", shellQuoteCommand(c.Gopass.Command, args), err)) - return "" + panic(err) } - var password string - if index := bytes.IndexByte(output, '\n'); index != -1 { - password = string(output[:index]) - } else { - password = string(output) - } + passwordBytes, _, _ := bytes.Cut(output, []byte{'\n'}) + password := string(passwordBytes) if c.Gopass.cache == nil { c.Gopass.cache = make(map[string]string) @@ -100,21 +48,33 @@ func (c *Config) gopassTemplateFunc(id string) string { return password } -func (c *Config) gopassVersionCheck() error { - output, err := c.gopassOutput("--version") +func (c *Config) gopassRawTemplateFunc(id string) string { + if output, ok := c.Gopass.rawCache[id]; ok { + return string(output) + } + + args := []string{"show", "--noparsing", id} + output, err := c.gopassOutput(args...) if err != nil { - return err + panic(err) } - m := gopassVersionRx.FindSubmatch(output) - if m == nil { - return fmt.Errorf("%s: could not extract version", output) + + if c.Gopass.rawCache == nil { + c.Gopass.rawCache = make(map[string][]byte) } - version, err := semver.NewVersion(string(m[1])) + c.Gopass.rawCache[id] = output + + return string(output) +} + +func (c *Config) gopassOutput(args ...string) ([]byte, error) { + name := c.Gopass.Command + cmd := exec.Command(name, args...) + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) if err != nil { - return err - } - if version.LessThan(gopassMinVersion) { - return fmt.Errorf("version %s found, need version %s or later", version, gopassMinVersion) + return nil, newCmdOutputError(cmd, output, err) } - return nil + return output, nil } diff --git a/internal/cmd/hcpvaultsecretsttemplatefuncs.go b/internal/cmd/hcpvaultsecretsttemplatefuncs.go new file mode 100644 index 00000000000..2e4e49cbe88 --- /dev/null +++ b/internal/cmd/hcpvaultsecretsttemplatefuncs.go @@ -0,0 +1,109 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "slices" + "strings" + + "github.com/coreos/go-semver/semver" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +type hcpVaultSecretConfig struct { + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + ApplicationName string `json:"applicationName" mapstructure:"applicationName" yaml:"applicationName"` + OrganizationID string `json:"organizationId" mapstructure:"organizationId" yaml:"organizationId"` + ProjectID string `json:"projectId" mapstructure:"projectId" yaml:"projectId"` + outputCache map[string][]byte +} + +var vltMinVersion = semver.Version{Major: 0, Minor: 2, Patch: 1} + +func (c *Config) hcpVaultSecretTemplateFunc(key string, additionalArgs ...string) string { + args, err := c.appendHCPVaultSecretsAdditionalArgs( + []string{"secrets", "get", "--plaintext"}, + additionalArgs, + ) + if err != nil { + panic(err) + } + output, err := c.vltOutput(append(args, key)) + if err != nil { + panic(err) + } + return string(output) +} + +func (c *Config) hcpVaultSecretJSONTemplateFunc(key string, additionalArgs ...string) any { + args, err := c.appendHCPVaultSecretsAdditionalArgs( + []string{"secrets", "get", "--format", "json"}, + additionalArgs, + ) + if err != nil { + panic(err) + } + data, err := c.vltOutput(append(args, key)) + if err != nil { + panic(err) + } + var value any + if err := json.Unmarshal(data, &value); err != nil { + panic(err) + } + return value +} + +func (c *Config) appendHCPVaultSecretsAdditionalArgs(args, additionalArgs []string) ([]string, error) { + if len(additionalArgs) > 0 && additionalArgs[0] != "" { + args = append(args, "--app-name", additionalArgs[0]) + } else if c.HCPVaultSecrets.ApplicationName != "" { + args = append(args, "--app-name", c.HCPVaultSecrets.ApplicationName) + } + if len(additionalArgs) > 1 && additionalArgs[1] != "" { + args = append(args, "--project", additionalArgs[1]) + } else if c.HCPVaultSecrets.ProjectID != "" { + args = append(args, "--project", c.HCPVaultSecrets.ProjectID) + } + if len(additionalArgs) > 2 && additionalArgs[2] != "" { + args = append(args, "--organization", additionalArgs[2]) + } else if c.HCPVaultSecrets.OrganizationID != "" { + args = append(args, "--organization", c.HCPVaultSecrets.OrganizationID) + } + if len(additionalArgs) > 3 { + // Add one to the number of received arguments as the hcpVaultSecret + // and hcpVaultSecretJson template functions report this error and take + // the key as the first argument. + return nil, fmt.Errorf("expected 1 to 4 arguments, got %d", len(additionalArgs)+1) + } + return args, nil +} + +func (c *Config) vltOutput(args []string) ([]byte, error) { + args = append(slices.Clone(c.HCPVaultSecrets.Args), args...) + key := strings.Join(args, "\x00") + if data, ok := c.HCPVaultSecrets.outputCache[key]; ok { + return data, nil + } + + cmd := exec.Command(c.HCPVaultSecrets.Command, args...) //nolint:gosec + // Always run the vlt command in the destination path because vlt uses + // relative paths to find its .vlt.json config file. + cmd.Dir = c.DestDirAbsPath.String() + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) + if err != nil { + return nil, newCmdOutputError(cmd, output, err) + } + + if c.HCPVaultSecrets.outputCache == nil { + c.HCPVaultSecrets.outputCache = make(map[string][]byte) + } + c.HCPVaultSecrets.outputCache[key] = output + return output, nil +} diff --git a/internal/cmd/helpcmd.go b/internal/cmd/helpcmd.go index 204a1e26773..d5df0452175 100644 --- a/internal/cmd/helpcmd.go +++ b/internal/cmd/helpcmd.go @@ -14,6 +14,9 @@ func (c *Config) newHelpCmd() *cobra.Command { Long: mustLongHelp("help"), Example: example("help"), RunE: c.runHelpCmd, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), } return helpCmd diff --git a/internal/cmd/ignoredcmd.go b/internal/cmd/ignoredcmd.go new file mode 100644 index 00000000000..fe2aa160f0b --- /dev/null +++ b/internal/cmd/ignoredcmd.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +type ignoredCmdConfig struct { + tree bool +} + +func (c *Config) newIgnoredCmd() *cobra.Command { + ignoredCmd := &cobra.Command{ + Use: "ignored", + Short: "Print ignored targets", + Long: mustLongHelp("ignored"), + Example: example("ignored"), + Args: cobra.NoArgs, + RunE: c.makeRunEWithSourceState(c.runIgnoredCmd), + Annotations: newAnnotations(), + } + + ignoredCmd.Flags().BoolVarP(&c.ignored.tree, "tree", "t", c.ignored.tree, "Print paths as a tree") + + return ignoredCmd +} + +func (c *Config) runIgnoredCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + return c.writePaths(stringersToStrings(sourceState.Ignored()), writePathsOptions{ + tree: c.ignored.tree, + }) +} diff --git a/internal/cmd/importcmd.go b/internal/cmd/importcmd.go index ba249de4929..a5dd3309b2e 100644 --- a/internal/cmd/importcmd.go +++ b/internal/cmd/importcmd.go @@ -9,10 +9,9 @@ import ( ) type importCmdConfig struct { - exclude *chezmoi.EntryTypeSet destination chezmoi.AbsPath exact bool - include *chezmoi.EntryTypeSet + filter *chezmoi.EntryTypeFilter removeDestination bool stripComponents int } @@ -25,20 +24,21 @@ func (c *Config) newImportCmd() *cobra.Command { Example: example("import"), Args: cobra.MaximumNArgs(1), RunE: c.makeRunEWithSourceState(c.runImportCmd), - Annotations: map[string]string{ - modifiesSourceDirectory: "true", - persistentStateMode: persistentStateModeReadWrite, - requiresSourceDirectory: "true", - }, + Annotations: newAnnotations( + createSourceDirectoryIfNeeded, + modifiesSourceDirectory, + persistentStateModeReadWrite, + ), } - flags := importCmd.Flags() - flags.VarP(&c._import.destination, "destination", "d", "Set destination prefix") - flags.BoolVar(&c._import.exact, "exact", c._import.exact, "Set exact_ attribute on imported directories") - flags.VarP(c._import.exclude, "exclude", "x", "Exclude entry types") - 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") //nolint:lll - flags.IntVar(&c._import.stripComponents, "strip-components", c._import.stripComponents, "Strip leading path components") //nolint:lll + importCmd.Flags().VarP(&c._import.destination, "destination", "d", "Set destination prefix") + importCmd.Flags().BoolVar(&c._import.exact, "exact", c._import.exact, "Set exact_ attribute on imported directories") + importCmd.Flags().VarP(c._import.filter.Exclude, "exclude", "x", "Exclude entry types") + importCmd.Flags().VarP(c._import.filter.Include, "include", "i", "Include entry types") + importCmd.Flags(). + BoolVarP(&c._import.removeDestination, "remove-destination", "r", c._import.removeDestination, "Remove destination before import") + importCmd.Flags(). + IntVar(&c._import.stripComponents, "strip-components", c._import.stripComponents, "Strip leading path components") return importCmd } @@ -83,9 +83,14 @@ func (c *Config) runImportCmd(cmd *cobra.Command, args []string, sourceState *ch } } return sourceState.Add( - c.sourceSystem, c.persistentState, archiveReaderSystem, archiveReaderSystem.FileInfos(), &chezmoi.AddOptions{ + c.sourceSystem, + c.persistentState, + archiveReaderSystem, + archiveReaderSystem.FileInfos(), + &chezmoi.AddOptions{ + Errorf: c.errorf, Exact: c._import.exact, - Include: c._import.include.Sub(c._import.exclude), + Filter: c._import.filter, RemoveDir: removeDir, }, ) diff --git a/internal/cmd/importcmd_test.go b/internal/cmd/importcmd_test.go index 5a03f75574e..5c9d7375d93 100644 --- a/internal/cmd/importcmd_test.go +++ b/internal/cmd/importcmd_test.go @@ -1,70 +1,53 @@ package cmd import ( - "archive/tar" "bytes" + "io/fs" "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" + "github.com/twpayne/chezmoi/v2/internal/archivetest" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) func TestImportCmd(t *testing.T) { - buffer := &bytes.Buffer{} - tarWriter := tar.NewWriter(buffer) - assert.NoError(t, tarWriter.WriteHeader(&tar.Header{ - Typeflag: tar.TypeDir, - Name: "archive/", - Mode: 0o777, - })) - assert.NoError(t, tarWriter.WriteHeader(&tar.Header{ - Typeflag: tar.TypeDir, - Name: "archive/.dir/", - Mode: 0o777, - })) - data := []byte("# contents of archive/.dir/.file\n") - assert.NoError(t, tarWriter.WriteHeader(&tar.Header{ - Typeflag: tar.TypeReg, - Name: "archive/.dir/.file", - Size: int64(len(data)), - Mode: 0o666, - })) - _, err := tarWriter.Write(data) + data, err := archivetest.NewTar(map[string]any{ + "archive": map[string]any{ + ".dir": map[string]any{ + ".file": "# contents of archive/.dir/.file\n", + ".symlink": &archivetest.Symlink{ + Target: ".file", + }, + }, + }, + }) assert.NoError(t, err) - linkname := ".file" - assert.NoError(t, tarWriter.WriteHeader(&tar.Header{ - Typeflag: tar.TypeSymlink, - Name: "archive/.dir/.symlink", - Linkname: linkname, - })) - require.NoError(t, tarWriter.Close()) for _, tc := range []struct { args []string - extraRoot interface{} - tests []interface{} + extraRoot any + tests []any }{ { args: []string{ "--strip-components=1", }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/dot_file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of archive/.dir/.file\n"), ), vfst.TestPath("/home/user/.local/share/chezmoi/dot_dir/symlink_dot_symlink", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString(".file\n"), ), @@ -75,21 +58,21 @@ func TestImportCmd(t *testing.T) { "--destination=~/dir", "--strip-components=1", }, - extraRoot: map[string]interface{}{ - "/home/user/.local/share/chezmoi/dir": &vfst.Dir{Perm: 0o777}, + extraRoot: map[string]any{ + "/home/user/.local/share/chezmoi/dir": &vfst.Dir{Perm: fs.ModePerm}, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir/dot_file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of archive/.dir/.file\n"), ), vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir/symlink_dot_symlink", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString(".file\n"), ), @@ -101,24 +84,24 @@ func TestImportCmd(t *testing.T) { "--remove-destination", "--strip-components=1", }, - extraRoot: map[string]interface{}{ + extraRoot: map[string]any{ "/home/user/.local/share/chezmoi/dir/file": "# contents of dir/file\n", }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dir/file", - vfst.TestDoesNotExist, + vfst.TestDoesNotExist(), ), vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir/dot_file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of archive/.dir/.file\n"), ), vfst.TestPath("/home/user/.local/share/chezmoi/dir/dot_dir/symlink_dot_symlink", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString(".file\n"), ), @@ -130,21 +113,21 @@ func TestImportCmd(t *testing.T) { "--exact", "--strip-components=1", }, - extraRoot: map[string]interface{}{ - "/home/user/.local/share/chezmoi/dir": &vfst.Dir{Perm: 0o777}, + extraRoot: map[string]any{ + "/home/user/.local/share/chezmoi/dir": &vfst.Dir{Perm: fs.ModePerm}, }, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dir/exact_dot_dir", - vfst.TestIsDir, - vfst.TestModePerm(0o777&^chezmoitest.Umask), + vfst.TestIsDir(), + vfst.TestModePerm(fs.ModePerm&^chezmoitest.Umask), ), vfst.TestPath("/home/user/.local/share/chezmoi/dir/exact_dot_dir/dot_file", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), 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.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString(".file\n"), ), @@ -152,14 +135,14 @@ func TestImportCmd(t *testing.T) { }, } { t.Run(strings.Join(tc.args, "_"), func(t *testing.T) { - chezmoitest.WithTestFS(t, map[string]interface{}{ - "/home/user": &vfst.Dir{Perm: 0o777}, + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user": &vfst.Dir{Perm: fs.ModePerm}, }, func(fileSystem vfs.FS) { if tc.extraRoot != nil { - require.NoError(t, vfst.NewBuilder().Build(fileSystem, tc.extraRoot)) + assert.NoError(t, vfst.NewBuilder().Build(fileSystem, tc.extraRoot)) } - config := newTestConfig(t, fileSystem, withStdin(bytes.NewReader(buffer.Bytes()))) - require.NoError(t, config.execute(append([]string{"import"}, tc.args...))) + config := newTestConfig(t, fileSystem, withStdin(bytes.NewReader(data))) + assert.NoError(t, config.execute(append([]string{"import"}, tc.args...))) vfst.RunTests(t, fileSystem, "", tc.tests...) }) }) diff --git a/internal/cmd/initcmd.go b/internal/cmd/initcmd.go index 077e7370a89..7c61b0e9c28 100644 --- a/internal/cmd/initcmd.go +++ b/internal/cmd/initcmd.go @@ -4,92 +4,85 @@ import ( "errors" "fmt" "io/fs" + "log/slog" "regexp" - "runtime" "strconv" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) type initCmdConfig struct { - apply bool - branch string - configPath chezmoi.AbsPath - data bool - depth int - exclude *chezmoi.EntryTypeSet - oneShot bool - purge bool - purgeBinary bool - ssh bool + apply bool + branch string + configPath chezmoi.AbsPath + data bool + depth int + filter *chezmoi.EntryTypeFilter + guessRepoURL bool + oneShot bool + purge bool + purgeBinary bool + recurseSubmodules bool + ssh bool } -var dotfilesRepoGuesses = []struct { - rx *regexp.Regexp - httpRepoGuessRepl string - httpUsernameGuessRepl string - sshRepoGuessRepl string +var repoGuesses = []struct { + rx *regexp.Regexp + httpRepoGuessRepl string + sshRepoGuessRepl string }{ { - rx: regexp.MustCompile(`\A([-0-9A-Za-z]+)\z`), - httpRepoGuessRepl: "https://github.com/$1/dotfiles.git", - httpUsernameGuessRepl: "$1", - sshRepoGuessRepl: "git@github.com:$1/dotfiles.git", + rx: regexp.MustCompile(`\A([-0-9A-Za-z]+)\z`), + httpRepoGuessRepl: "https://github.com/$1/dotfiles.git", + sshRepoGuessRepl: "git@github.com:$1/dotfiles.git", }, { - rx: regexp.MustCompile(`\A([-0-9A-Za-z]+)/([-0-9A-Za-z]+)(\.git)?\z`), - httpRepoGuessRepl: "https://github.com/$1/$2.git", - httpUsernameGuessRepl: "$1", - sshRepoGuessRepl: "git@github.com:$1/$2.git", + rx: regexp.MustCompile(`\A([-0-9A-Za-z]+)/([-\.0-9A-Z_a-z]+?)(\.git)?\z`), + httpRepoGuessRepl: "https://github.com/$1/$2.git", + sshRepoGuessRepl: "git@github.com:$1/$2.git", }, { - rx: regexp.MustCompile(`\A([-.0-9A-Za-z]+)/([-0-9A-Za-z]+)\z`), - httpRepoGuessRepl: "https://$1/$2/dotfiles.git", - httpUsernameGuessRepl: "$2", - sshRepoGuessRepl: "git@$1:$2/dotfiles.git", + rx: regexp.MustCompile(`\A([-.0-9A-Za-z]+)/([-0-9A-Za-z]+)\z`), + httpRepoGuessRepl: "https://$1/$2/dotfiles.git", + sshRepoGuessRepl: "git@$1:$2/dotfiles.git", }, { - rx: regexp.MustCompile(`\A([-0-9A-Za-z]+)/([-0-9A-Za-z]+)/([-.0-9A-Za-z]+)\z`), - httpRepoGuessRepl: "https://$1/$2/$3.git", - httpUsernameGuessRepl: "$2", - sshRepoGuessRepl: "git@$1:$2/$3.git", + rx: regexp.MustCompile(`\A([-0-9A-Za-z]+)/([-0-9A-Za-z]+)/([-.0-9A-Za-z]+)\z`), + httpRepoGuessRepl: "https://$1/$2/$3.git", + sshRepoGuessRepl: "git@$1:$2/$3.git", }, { - rx: regexp.MustCompile(`\A([-.0-9A-Za-z]+)/([-0-9A-Za-z]+)/([-0-9A-Za-z]+)(\.git)?\z`), - httpRepoGuessRepl: "https://$1/$2/$3.git", - httpUsernameGuessRepl: "$2", - sshRepoGuessRepl: "git@$1:$2/$3.git", + rx: regexp.MustCompile(`\A([-.0-9A-Za-z]+)/([-0-9A-Za-z]+)/([-0-9A-Za-z]+)(\.git)?\z`), + httpRepoGuessRepl: "https://$1/$2/$3.git", + sshRepoGuessRepl: "git@$1:$2/$3.git", }, { - rx: regexp.MustCompile(`\A(https?://)([-.0-9A-Za-z]+)/([-0-9A-Za-z]+)/([-0-9A-Za-z]+)(\.git)?\z`), - httpRepoGuessRepl: "$1$2/$3/$4.git", - httpUsernameGuessRepl: "$3", - sshRepoGuessRepl: "git@$2:$3/$4.git", + rx: regexp.MustCompile(`\A(https?://)([-.0-9A-Za-z]+)/([-0-9A-Za-z]+)/([-0-9A-Za-z]+)(\.git)?\z`), + httpRepoGuessRepl: "$1$2/$3/$4.git", + sshRepoGuessRepl: "git@$2:$3/$4.git", }, { - rx: regexp.MustCompile(`\Asr\.ht/~([-0-9A-Za-z]+)\z`), - httpRepoGuessRepl: "https://git.sr.ht/~$1/dotfiles", - httpUsernameGuessRepl: "$1", - sshRepoGuessRepl: "git@git.sr.ht:~$1/dotfiles", + rx: regexp.MustCompile(`\Asr\.ht/~([a-z_][a-z0-9_-]+)\z`), + httpRepoGuessRepl: "https://git.sr.ht/~$1/dotfiles", + sshRepoGuessRepl: "git@git.sr.ht:~$1/dotfiles", }, { - rx: regexp.MustCompile(`\Asr\.ht/~([-0-9A-Za-z]+)/([-0-9A-Za-z]+)\z`), - httpRepoGuessRepl: "https://git.sr.ht/~$1/$2", - httpUsernameGuessRepl: "$1", - sshRepoGuessRepl: "git@git.sr.ht:~$1/$2", + rx: regexp.MustCompile(`\Asr\.ht/~([a-z_][a-z0-9_-]+)/([-0-9A-Za-z]+)\z`), + httpRepoGuessRepl: "https://git.sr.ht/~$1/$2", + sshRepoGuessRepl: "git@git.sr.ht:~$1/$2", }, } -// A loggableGitCloneOptions is a git.CloneOptions that implements -// github.com/rs/zerolog.LogObjectMarshaler. -type loggableGitCloneOptions git.CloneOptions +// A gitCloneOptionsLogValuer is a git.CloneOptions that implements +// log/slog.LogValuer. +type gitCloneOptionsLogValuer git.CloneOptions func (c *Config) newInitCmd() *cobra.Command { initCmd := &cobra.Command{ @@ -99,26 +92,30 @@ func (c *Config) newInitCmd() *cobra.Command { Long: mustLongHelp("init"), Example: example("init"), RunE: c.runInitCmd, - Annotations: map[string]string{ - modifiesDestinationDirectory: "true", - persistentStateMode: persistentStateModeReadWrite, - requiresSourceDirectory: "true", - requiresWorkingTree: "true", - runsCommands: "true", - }, + Annotations: newAnnotations( + createSourceDirectoryIfNeeded, + modifiesDestinationDirectory, + persistentStateModeReadWrite, + requiresWorkingTree, + runsCommands, + ), } - flags := initCmd.Flags() - flags.BoolVarP(&c.init.apply, "apply", "a", c.init.apply, "update destination directory") - flags.VarP(&c.init.configPath, "config-path", "C", "Path to write generated config file") - flags.BoolVar(&c.init.data, "data", c.init.data, "Include existing template data") - flags.IntVarP(&c.init.depth, "depth", "d", c.init.depth, "Create a shallow clone") - flags.VarP(c.init.exclude, "exclude", "x", "Exclude entry types") - flags.BoolVar(&c.init.oneShot, "one-shot", c.init.oneShot, "Run in one-shot mode") - flags.BoolVarP(&c.init.purge, "purge", "p", c.init.purge, "Purge config and source directories after running") - flags.BoolVarP(&c.init.purgeBinary, "purge-binary", "P", c.init.purgeBinary, "Purge chezmoi binary after running") - flags.StringVar(&c.init.branch, "branch", c.init.branch, "Set initial branch to checkout") - flags.BoolVar(&c.init.ssh, "ssh", false, "Use ssh instead of https when guessing dotfile repo URL") + c.addInteractiveTemplateFuncFlags(initCmd.Flags()) + initCmd.Flags().BoolVarP(&c.init.apply, "apply", "a", c.init.apply, "Update destination directory") + initCmd.Flags().StringVar(&c.init.branch, "branch", c.init.branch, "Set initial branch to checkout") + initCmd.Flags().VarP(&c.init.configPath, "config-path", "C", "Path to write generated config file") + initCmd.Flags().BoolVar(&c.init.data, "data", c.init.data, "Include existing template data") + initCmd.Flags().IntVarP(&c.init.depth, "depth", "d", c.init.depth, "Create a shallow clone") + initCmd.Flags().VarP(c.init.filter.Exclude, "exclude", "x", "Exclude entry types") + initCmd.Flags().BoolVarP(&c.init.guessRepoURL, "guess-repo-url", "g", c.init.guessRepoURL, "Guess the repo URL") + initCmd.Flags().VarP(c.init.filter.Include, "include", "i", "Include entry types") + initCmd.Flags().BoolVar(&c.init.oneShot, "one-shot", c.init.oneShot, "Run in one-shot mode") + initCmd.Flags().BoolVarP(&c.init.purge, "purge", "p", c.init.purge, "Purge config and source directories after running") + initCmd.Flags().BoolVarP(&c.init.purgeBinary, "purge-binary", "P", c.init.purgeBinary, "Purge chezmoi binary after running") + initCmd.Flags(). + BoolVar(&c.init.recurseSubmodules, "recurse-submodules", c.init.recurseSubmodules, "Checkout submodules recursively") + initCmd.Flags().BoolVar(&c.init.ssh, "ssh", c.init.ssh, "Use ssh instead of https when guessing repo URL") return initCmd } @@ -134,10 +131,7 @@ func (c *Config) runInitCmd(cmd *cobra.Command, args []string) error { // If we're not in a working tree then init it or clone it. gitDirAbsPath := c.WorkingTreeAbsPath.JoinString(git.GitDirName) - switch fileInfo, err := c.baseSystem.Stat(gitDirAbsPath); { - case err == nil && fileInfo.IsDir(): - case err == nil && !fileInfo.IsDir(): - return fmt.Errorf("%s: not a directory", gitDirAbsPath) + switch _, err := c.baseSystem.Stat(gitDirAbsPath); { case errors.Is(err, fs.ErrNotExist): workingTreeRawPath, err := c.baseSystem.RawPath(c.WorkingTreeAbsPath) if err != nil { @@ -155,15 +149,24 @@ func (c *Config) runInitCmd(cmd *cobra.Command, args []string) error { return err } } else { - username, dotfilesRepoURL := guessDotfilesRepoURL(args[0], c.init.ssh) + var repoURLStr string + if c.init.guessRepoURL { + repoURLStr = guessRepoURL(args[0], c.init.ssh) + } else { + repoURLStr = args[0] + } if useBuiltinGit { - if err := c.builtinGitClone(username, dotfilesRepoURL, workingTreeRawPath); err != nil { + if err := c.builtinGitClone(repoURLStr, workingTreeRawPath); err != nil { return err } } else { args := []string{ "clone", - "--recurse-submodules", + } + if c.init.recurseSubmodules { + args = append(args, + "--recurse-submodules", + ) } if c.init.branch != "" { args = append(args, @@ -175,10 +178,7 @@ func (c *Config) runInitCmd(cmd *cobra.Command, args []string) error { "--depth", strconv.Itoa(c.init.depth), ) } - args = append(args, - dotfilesRepoURL, - workingTreeRawPath.String(), - ) + args = append(args, repoURLStr, workingTreeRawPath.String()) if err := c.run(chezmoi.EmptyAbsPath, c.Git.Command, args); err != nil { return err } @@ -188,14 +188,19 @@ func (c *Config) runInitCmd(cmd *cobra.Command, args []string) error { return err } - if err := c.createAndReloadConfigFile(); err != nil { + if err := c.checkVersion(); err != nil { + return err + } + + if err := c.createAndReloadConfigFile(cmd); err != nil { return err } // Apply. if c.init.apply { if err := c.applyArgs(cmd.Context(), c.destSystem, c.DestDirAbsPath, noArgs, applyArgsOptions{ - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll).Sub(c.init.exclude), + cmd: cmd, + filter: c.init.filter, recursive: false, umask: c.Umask, preApplyFunc: c.defaultPreApplyFunc, @@ -205,9 +210,14 @@ func (c *Config) runInitCmd(cmd *cobra.Command, args []string) error { } // Purge. - if c.init.purge { - if err := c.doPurge(&purgeOptions{ - binary: runtime.GOOS != "windows" && c.init.purgeBinary, + if c.init.purge || c.init.purgeBinary { + if err := c.doPurge(&doPurgeOptions{ + binary: c.init.purgeBinary, + cache: c.init.purge, + config: c.init.purge, + persistentState: c.init.purge, + sourceDir: c.init.purge, + workingTree: c.init.purge, }); err != nil { return err } @@ -217,40 +227,46 @@ func (c *Config) runInitCmd(cmd *cobra.Command, args []string) error { } // builtinGitClone clones a repo using the builtin git command. -func (c *Config) builtinGitClone(username, url string, workingTreeRawPath chezmoi.AbsPath) error { +func (c *Config) builtinGitClone(repoURLStr string, workingTreeRawPath chezmoi.AbsPath) error { + endpoint, err := transport.NewEndpoint(repoURLStr) + if err != nil { + return err + } + if c.init.ssh || endpoint.Protocol == "ssh" { + return errors.New("builtin git does not support cloning repos over ssh, please install git") + } + isBare := false var referenceName plumbing.ReferenceName if c.init.branch != "" { referenceName = plumbing.NewBranchReferenceName(c.init.branch) } cloneOptions := git.CloneOptions{ - URL: url, + URL: repoURLStr, Depth: c.init.depth, ReferenceName: referenceName, RecurseSubmodules: git.DefaultSubmoduleRecursionDepth, + ShallowSubmodules: c.init.depth == 1, } for { _, err := git.PlainClone(workingTreeRawPath.String(), isBare, &cloneOptions) - c.logger.Err(err). - Stringer("path", workingTreeRawPath). - Bool("isBare", isBare). - Object("o", loggableGitCloneOptions(cloneOptions)). - Msg("PlainClone") + chezmoilog.InfoOrError(c.logger, "PlainClone", err, + chezmoilog.Stringer("path", workingTreeRawPath), + slog.Bool("isBare", isBare), + slog.Any("options", gitCloneOptionsLogValuer(cloneOptions)), + ) if !errors.Is(err, transport.ErrAuthenticationRequired) { return err } - if _, err := fmt.Fprintf(c.stdout, "chezmoi: %s: %v\n", url, err); err != nil { + if _, err := fmt.Fprintf(c.stdout, "chezmoi: %s: %v\n", repoURLStr, err); err != nil { return err } var basicAuth http.BasicAuth - if basicAuth.Username, err = c.readLine(fmt.Sprintf("Username [default %q]? ", username)); err != nil { + if basicAuth.Username, err = c.readString("Username? ", nil); err != nil { return err } - if basicAuth.Username == "" { - basicAuth.Username = username - } if basicAuth.Password, err = c.readPassword("Password? "); err != nil { return err } @@ -262,70 +278,63 @@ func (c *Config) builtinGitClone(username, url string, workingTreeRawPath chezmo func (c *Config) builtinGitInit(workingTreeRawPath chezmoi.AbsPath) error { isBare := false _, err := git.PlainInit(workingTreeRawPath.String(), isBare) - c.logger.Err(err). - Stringer("path", workingTreeRawPath). - Bool("isBare", isBare). - Msg("PlainInit") + chezmoilog.InfoOrError(c.logger, "PlainInit", err, + chezmoilog.Stringer("path", workingTreeRawPath), + slog.Bool("isBare", isBare), + ) return err } -// MarshalZerologObject implements -// github.com/rs/zerolog.LogObjectMarshaler.MarshalZerologObject. -// -// We cannot use zerolog's default object marshaler because it logs the auth -// credentials. -func (o loggableGitCloneOptions) MarshalZerologObject(e *zerolog.Event) { +// LogValue implements log/slog.LogValuer.LogValue. +func (o gitCloneOptionsLogValuer) LogValue() slog.Value { + var attrs []slog.Attr if o.URL != "" { - e.Str("URL", o.URL) + attrs = append(attrs, slog.String("URL", o.URL)) } if o.Auth != nil { - e.Stringer("Auth", o.Auth) + attrs = append(attrs, chezmoilog.Stringer("Auth", o.Auth)) } if o.RemoteName != "" { - e.Str("RemoteName", o.RemoteName) + attrs = append(attrs, slog.String("RemoteName", o.RemoteName)) } if o.ReferenceName != "" { - e.Stringer("ReferenceName", o.ReferenceName) + attrs = append(attrs, slog.String("ReferenceName", string(o.ReferenceName))) } if o.SingleBranch { - e.Bool("SingleBranch", o.SingleBranch) + attrs = append(attrs, slog.Bool("SingleBranch", o.SingleBranch)) } if o.NoCheckout { - e.Bool("NoCheckout", o.NoCheckout) + attrs = append(attrs, slog.Bool("NoCheckout", o.NoCheckout)) } if o.Depth != 0 { - e.Int("Depth", o.Depth) + attrs = append(attrs, slog.Int("Depth", o.Depth)) } if o.RecurseSubmodules != 0 { - e.Uint("RecurseSubmodules", uint(o.RecurseSubmodules)) + attrs = append(attrs, slog.Int("RecurseSubmodules", int(o.RecurseSubmodules))) } if o.Tags != 0 { - e.Int("Tags", int(o.Tags)) + attrs = append(attrs, slog.Int("Tags", int(o.Tags))) } if o.InsecureSkipTLS { - e.Bool("InsecureSkipTLS", o.InsecureSkipTLS) + attrs = append(attrs, slog.Bool("InsecureSkipTLS", o.InsecureSkipTLS)) } if o.CABundle != nil { - e.Bytes("CABundle", o.CABundle) + attrs = append(attrs, slog.Any("CABundle", o.CABundle)) } + return slog.GroupValue(attrs...) } -// guessDotfilesRepoURL guesses the user's username and dotfile repo from arg. -func guessDotfilesRepoURL(arg string, ssh bool) (username, repo string) { - for _, dotfileRepoGuess := range dotfilesRepoGuesses { - if !dotfileRepoGuess.rx.MatchString(arg) { - continue - } +// guessRepoURL guesses the user's username and repo from arg. +func guessRepoURL(arg string, ssh bool) string { + for _, repoGuess := range repoGuesses { switch { - case ssh && dotfileRepoGuess.sshRepoGuessRepl != "": - repo = dotfileRepoGuess.rx.ReplaceAllString(arg, dotfileRepoGuess.sshRepoGuessRepl) - return - case !ssh && dotfileRepoGuess.httpRepoGuessRepl != "": - username = dotfileRepoGuess.rx.ReplaceAllString(arg, dotfileRepoGuess.httpUsernameGuessRepl) - repo = dotfileRepoGuess.rx.ReplaceAllString(arg, dotfileRepoGuess.httpRepoGuessRepl) - return + case !repoGuess.rx.MatchString(arg): + continue + case ssh && repoGuess.sshRepoGuessRepl != "": + return repoGuess.rx.ReplaceAllString(arg, repoGuess.sshRepoGuessRepl) + case !ssh && repoGuess.httpRepoGuessRepl != "": + return repoGuess.rx.ReplaceAllString(arg, repoGuess.httpRepoGuessRepl) } } - repo = arg - return + return arg } diff --git a/internal/cmd/initcmd_test.go b/internal/cmd/initcmd_test.go index 282effddbee..ff1123ad5c3 100644 --- a/internal/cmd/initcmd_test.go +++ b/internal/cmd/initcmd_test.go @@ -1,17 +1,22 @@ package cmd import ( + "errors" + "runtime" "testing" - "github.com/stretchr/testify/assert" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) -func TestGuessDotfilesRepoURL(t *testing.T) { +func TestGuessRepoURL(t *testing.T) { for _, tc := range []struct { - arg string - expectedHTTPRepoURL string - expectedHTTPUsername string - expectedSSHRepoURL string + arg string + expectedHTTPRepoURL string + expectedSSHRepoURL string }{ { arg: "git@github.com:user/dotfiles.git", @@ -19,76 +24,118 @@ func TestGuessDotfilesRepoURL(t *testing.T) { expectedSSHRepoURL: "git@github.com:user/dotfiles.git", }, { - arg: "gitlab.com/user", - expectedHTTPRepoURL: "https://gitlab.com/user/dotfiles.git", - expectedHTTPUsername: "user", - expectedSSHRepoURL: "git@gitlab.com:user/dotfiles.git", + arg: "codeberg.org/user", + expectedHTTPRepoURL: "https://codeberg.org/user/dotfiles.git", + expectedSSHRepoURL: "git@codeberg.org:user/dotfiles.git", }, { - arg: "gitlab.com/user/dots", - expectedHTTPRepoURL: "https://gitlab.com/user/dots.git", - expectedHTTPUsername: "user", - expectedSSHRepoURL: "git@gitlab.com:user/dots.git", + arg: "codeberg.org/user/dots", + expectedHTTPRepoURL: "https://codeberg.org/user/dots.git", + expectedSSHRepoURL: "git@codeberg.org:user/dots.git", }, { - arg: "gitlab.com/user/dots.git", - expectedHTTPRepoURL: "https://gitlab.com/user/dots.git", - expectedHTTPUsername: "user", - expectedSSHRepoURL: "git@gitlab.com:user/dots.git", + arg: "gitlab.com/user", + expectedHTTPRepoURL: "https://gitlab.com/user/dotfiles.git", + expectedSSHRepoURL: "git@gitlab.com:user/dotfiles.git", }, { - arg: "http://gitlab.com/user/dots.git", - expectedHTTPRepoURL: "http://gitlab.com/user/dots.git", - expectedHTTPUsername: "user", - expectedSSHRepoURL: "git@gitlab.com:user/dots.git", + arg: "gitlab.com/user/dots", + expectedHTTPRepoURL: "https://gitlab.com/user/dots.git", + expectedSSHRepoURL: "git@gitlab.com:user/dots.git", }, { - arg: "https://gitlab.com/user/dots.git", - expectedHTTPRepoURL: "https://gitlab.com/user/dots.git", - expectedHTTPUsername: "user", - expectedSSHRepoURL: "git@gitlab.com:user/dots.git", + arg: "gitlab.com/user/dots.git", + expectedHTTPRepoURL: "https://gitlab.com/user/dots.git", + expectedSSHRepoURL: "git@gitlab.com:user/dots.git", }, { - arg: "sr.ht/~user", - expectedHTTPRepoURL: "https://git.sr.ht/~user/dotfiles", - expectedHTTPUsername: "user", - expectedSSHRepoURL: "git@git.sr.ht:~user/dotfiles", + arg: "http://gitlab.com/user/dots.git", + expectedHTTPRepoURL: "http://gitlab.com/user/dots.git", + expectedSSHRepoURL: "git@gitlab.com:user/dots.git", }, { - arg: "sr.ht/~user/dots", - expectedHTTPRepoURL: "https://git.sr.ht/~user/dots", - expectedHTTPUsername: "user", - expectedSSHRepoURL: "git@git.sr.ht:~user/dots", + arg: "https://gitlab.com/user/dots.git", + expectedHTTPRepoURL: "https://gitlab.com/user/dots.git", + expectedSSHRepoURL: "git@gitlab.com:user/dots.git", }, { - arg: "user", - expectedHTTPRepoURL: "https://github.com/user/dotfiles.git", - expectedHTTPUsername: "user", - expectedSSHRepoURL: "git@github.com:user/dotfiles.git", + arg: "sr.ht/~user_name", + expectedHTTPRepoURL: "https://git.sr.ht/~user_name/dotfiles", + expectedSSHRepoURL: "git@git.sr.ht:~user_name/dotfiles", }, { - arg: "user/dots", - expectedHTTPRepoURL: "https://github.com/user/dots.git", - expectedHTTPUsername: "user", - expectedSSHRepoURL: "git@github.com:user/dots.git", + arg: "sr.ht/~user_name/dots", + expectedHTTPRepoURL: "https://git.sr.ht/~user_name/dots", + expectedSSHRepoURL: "git@git.sr.ht:~user_name/dots", }, { - arg: "user/dots.git", - expectedHTTPRepoURL: "https://github.com/user/dots.git", - expectedHTTPUsername: "user", - expectedSSHRepoURL: "git@github.com:user/dots.git", + arg: "user", + expectedHTTPRepoURL: "https://github.com/user/dotfiles.git", + expectedSSHRepoURL: "git@github.com:user/dotfiles.git", + }, + { + arg: "user/chezmoi_dotfiles", + expectedHTTPRepoURL: "https://github.com/user/chezmoi_dotfiles.git", + expectedSSHRepoURL: "git@github.com:user/chezmoi_dotfiles.git", + }, + { + arg: "user/.dotfiles", + expectedHTTPRepoURL: "https://github.com/user/.dotfiles.git", + expectedSSHRepoURL: "git@github.com:user/.dotfiles.git", + }, + { + arg: "user/dots", + expectedHTTPRepoURL: "https://github.com/user/dots.git", + expectedSSHRepoURL: "git@github.com:user/dots.git", + }, + { + arg: "user/dots.git", + expectedHTTPRepoURL: "https://github.com/user/dots.git", + expectedSSHRepoURL: "git@github.com:user/dots.git", }, } { t.Run(tc.arg, func(t *testing.T) { ssh := false - actualHTTPUsername, actualHTTPRepoURL := guessDotfilesRepoURL(tc.arg, ssh) - assert.Equal(t, tc.expectedHTTPUsername, actualHTTPUsername, "HTTPUsername") + actualHTTPRepoURL := guessRepoURL(tc.arg, ssh) assert.Equal(t, tc.expectedHTTPRepoURL, actualHTTPRepoURL, "HTTPRepoURL") ssh = true - actualSSHUsername, actualSSHRepoURL := guessDotfilesRepoURL(tc.arg, ssh) - assert.Equal(t, "", actualSSHUsername, "SSHUsername") + actualSSHRepoURL := guessRepoURL(tc.arg, ssh) assert.Equal(t, tc.expectedSSHRepoURL, actualSSHRepoURL, "SSHRepoURL") }) } } + +func TestIssue2137(t *testing.T) { + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ + ".chezmoiversion": "3.0.0", + ".git": map[string]any{ + ".keep": nil, + }, + }, + }, func(fileSystem vfs.FS) { + err := newTestConfig(t, fileSystem).execute([]string{"init"}) + tooOldError := &chezmoi.TooOldError{} + assert.True(t, errors.As(err, &tooOldError)) + }) +} + +func TestIssue2283(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping UNIX test on Windows") + } + chezmoitest.WithTestFS(t, map[string]any{ + "/home/user/.local/share/chezmoi": map[string]any{ + ".chezmoiroot": "home", + "home": map[string]any{ + ".chezmoi.yaml.tmpl": "sourceDir: {{ .chezmoi.sourceDir }}\n", + }, + }, + }, func(fileSystem vfs.FS) { + assert.NoError(t, newTestConfig(t, fileSystem).execute([]string{"init"})) + data, err := fileSystem.ReadFile("/home/user/.config/chezmoi/chezmoi.yaml") + assert.NoError(t, err) + assert.Equal(t, "sourceDir: /home/user/.local/share/chezmoi/home\n", string(data)) + }) +} diff --git a/internal/cmd/inittemplatefuncs.go b/internal/cmd/inittemplatefuncs.go index 9b976353cd1..89c45d36f92 100644 --- a/internal/cmd/inittemplatefuncs.go +++ b/internal/cmd/inittemplatefuncs.go @@ -1,99 +1,21 @@ package cmd import ( - "fmt" "os" - "strconv" - "strings" "golang.org/x/term" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) -func (c *Config) promptBool(field string, args ...bool) bool { - switch len(args) { - case 0: - value, err := parseBool(c.promptString(field)) - if err != nil { - returnTemplateError(err) - return false - } - return value - case 1: - promptStr := field + " (default " + strconv.FormatBool(args[0]) + ")" - valueStr := c.promptString(promptStr) - if valueStr == "" { - return args[0] - } - value, err := parseBool(valueStr) - if err != nil { - returnTemplateError(err) - return false - } - return value - default: - err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) - returnTemplateError(err) - return false - } +func (c *Config) exitInitTemplateFunc(code int) string { + panic(chezmoi.ExitCodeError(code)) } -func (c *Config) promptInt(field string, args ...int64) int64 { - switch len(args) { - case 0: - value, err := strconv.ParseInt(c.promptString(field), 10, 64) - if err != nil { - returnTemplateError(err) - return 0 - } - return value - case 1: - promptStr := field + " (default " + strconv.FormatInt(args[0], 10) + ")" - valueStr := c.promptString(promptStr) - if valueStr == "" { - return args[0] - } - value, err := strconv.ParseInt(valueStr, 10, 64) - if err != nil { - returnTemplateError(err) - return 0 - } - return value - default: - err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) - returnTemplateError(err) - return 0 - } -} - -func (c *Config) promptString(prompt string, args ...string) string { - switch len(args) { - case 0: - value, err := c.readLine(prompt + "? ") - if err != nil { - returnTemplateError(err) - return "" - } - return strings.TrimSpace(value) - case 1: - defaultStr := strings.TrimSpace(args[0]) - promptStr := prompt + " (default " + strconv.Quote(defaultStr) + ")? " - switch value, err := c.readLine(promptStr); { - case err != nil: - returnTemplateError(err) - return "" - case value == "": - return defaultStr - default: - return strings.TrimSpace(value) - } - default: - err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) - returnTemplateError(err) - return "" +func (c *Config) stdinIsATTYInitTemplateFunc() bool { + if c.noTTY { + return false } -} - -func (c *Config) stdinIsATTY() bool { file, ok := c.stdin.(*os.File) if !ok { return false @@ -104,7 +26,7 @@ func (c *Config) stdinIsATTY() bool { func (c *Config) writeToStdout(args ...string) string { for _, arg := range args { if _, err := c.stdout.Write([]byte(arg)); err != nil { - returnTemplateError(err) + panic(err) } } return "" diff --git a/internal/cmd/inittemplatefuncs_test.go b/internal/cmd/inittemplatefuncs_test.go index b0cc68d27f3..3454d185217 100644 --- a/internal/cmd/inittemplatefuncs_test.go +++ b/internal/cmd/inittemplatefuncs_test.go @@ -4,11 +4,10 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/alecthomas/assert/v2" ) -func TestPromptBool(t *testing.T) { +func TestPromptBoolInteractiveTemplateFunc(t *testing.T) { for _, tc := range []struct { name string prompt string @@ -64,23 +63,114 @@ func TestPromptBool(t *testing.T) { stdin := strings.NewReader(tc.stdinStr) stdout := &strings.Builder{} config, err := newConfig( + withNoTTY(true), withStdin(stdin), withStdout(stdout), ) - require.NoError(t, err) + assert.NoError(t, err) if tc.expectedErr { assert.Panics(t, func() { - config.promptBool(tc.prompt, tc.args...) + config.promptBoolInteractiveTemplateFunc(tc.prompt, tc.args...) }) } else { - assert.Equal(t, tc.expected, config.promptBool(tc.prompt, tc.args...)) + assert.Equal(t, tc.expected, config.promptBoolInteractiveTemplateFunc(tc.prompt, tc.args...)) assert.Equal(t, tc.expectedStdoutStr, stdout.String()) } }) } } -func TestPromptInt(t *testing.T) { +func TestPromptChoiceInteractiveTemplateFunc(t *testing.T) { + for _, tc := range []struct { + name string + prompt string + choices []any + args []string + stdinStr string + expectedStdoutStr string + expected string + expectedErr bool + }{ + { + name: "response_without_default", + prompt: "choice", + choices: []any{"one", "two", "three"}, + stdinStr: "one\n", + expectedStdoutStr: "choice (one/two/three)? ", + expected: "one", + }, + { + name: "response_with_default", + prompt: "choice", + choices: []any{"one", "two", "three"}, + args: []string{"one"}, + stdinStr: "two\n", + expectedStdoutStr: "choice (one/two/three, default one)? ", + expected: "two", + }, + { + name: "no_response_with_default", + prompt: "choice", + choices: []any{"one", "two", "three"}, + args: []string{"three"}, + stdinStr: "\n", + expectedStdoutStr: "choice (one/two/three, default three)? ", + expected: "three", + }, + { + name: "invalid_response", + prompt: "choice", + choices: []any{"one", "two", "three"}, + stdinStr: "invalid\n", + expectedErr: true, + }, + { + name: "invalid_response_with_default", + prompt: "choice", + choices: []any{"one", "two", "three"}, + args: []string{"one"}, + stdinStr: "invalid\n", + expectedErr: true, + }, + { + name: "too_many_args", + prompt: "choice", + choices: []any{"one", "two", "three"}, + args: []string{"two", "three"}, + stdinStr: "\n", + expectedErr: true, + }, + { + name: "invalid_default", + prompt: "choice", + choices: []any{"one", "two", "three"}, + args: []string{"four"}, + stdinStr: "\n", + expectedErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + stdin := strings.NewReader(tc.stdinStr) + stdout := &strings.Builder{} + config, err := newConfig( + withNoTTY(true), + withStdin(stdin), + withStdout(stdout), + ) + assert.NoError(t, err) + if tc.expectedErr { + assert.Panics(t, func() { + config.promptChoiceInteractiveTemplateFunc(tc.prompt, tc.choices, tc.args...) + }) + } else { + assert.Equal(t, tc.expected, config.promptChoiceInteractiveTemplateFunc(tc.prompt, tc.choices, tc.args...)) + assert.Equal(t, tc.expectedStdoutStr, stdout.String()) + } + }) + } +} + +func TestPromptIntInteractiveTemplateFunc(t *testing.T) { for _, tc := range []struct { name string prompt string @@ -136,23 +226,24 @@ func TestPromptInt(t *testing.T) { stdin := strings.NewReader(tc.stdinStr) stdout := &strings.Builder{} config, err := newConfig( + withNoTTY(true), withStdin(stdin), withStdout(stdout), ) - require.NoError(t, err) + assert.NoError(t, err) if tc.expectedErr { assert.Panics(t, func() { - config.promptInt(tc.prompt, tc.args...) + config.promptIntInteractiveTemplateFunc(tc.prompt, tc.args...) }) } else { - assert.Equal(t, tc.expected, config.promptInt(tc.prompt, tc.args...)) + assert.Equal(t, tc.expected, config.promptIntInteractiveTemplateFunc(tc.prompt, tc.args...)) assert.Equal(t, tc.expectedStdoutStr, stdout.String()) } }) } } -func TestPromptString(t *testing.T) { +func TestPromptStringInteractiveTemplateFunc(t *testing.T) { for _, tc := range []struct { name string prompt string @@ -221,16 +312,17 @@ func TestPromptString(t *testing.T) { stdin := strings.NewReader(tc.stdinStr) stdout := &strings.Builder{} config, err := newConfig( + withNoTTY(true), withStdin(stdin), withStdout(stdout), ) - require.NoError(t, err) + assert.NoError(t, err) if tc.expectedErr { assert.Panics(t, func() { - config.promptString(tc.prompt, tc.args...) + config.promptStringInteractiveTemplateFunc(tc.prompt, tc.args...) }) } else { - assert.Equal(t, tc.expected, config.promptString(tc.prompt, tc.args...)) + assert.Equal(t, tc.expected, config.promptStringInteractiveTemplateFunc(tc.prompt, tc.args...)) assert.Equal(t, tc.expectedStdoutStr, stdout.String()) } }) diff --git a/internal/cmd/interactivetemplatefuncs.go b/internal/cmd/interactivetemplatefuncs.go new file mode 100644 index 00000000000..cc6365a2153 --- /dev/null +++ b/internal/cmd/interactivetemplatefuncs.go @@ -0,0 +1,259 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/pflag" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +type interactiveTemplateFuncsConfig struct { + forcePromptOnce bool + promptBool map[string]string + promptChoice map[string]string + promptDefaults bool + promptInt map[string]int + promptString map[string]string +} + +func (c *Config) addInteractiveTemplateFuncFlags(flags *pflag.FlagSet) { + flags.BoolVar( + &c.interactiveTemplateFuncs.forcePromptOnce, + "prompt", + c.interactiveTemplateFuncs.forcePromptOnce, + "Force prompt*Once template functions to prompt", + ) + flags.BoolVar( + &c.interactiveTemplateFuncs.promptDefaults, + "promptDefaults", + c.interactiveTemplateFuncs.promptDefaults, + "Make prompt functions return default values", + ) + flags.StringToStringVar( + &c.interactiveTemplateFuncs.promptBool, + "promptBool", + c.interactiveTemplateFuncs.promptBool, + "Populate promptBool", + ) + flags.StringToStringVar( + &c.interactiveTemplateFuncs.promptChoice, + "promptChoice", + c.interactiveTemplateFuncs.promptChoice, + "Populate promptChoice", + ) + flags.StringToIntVar( + &c.interactiveTemplateFuncs.promptInt, + "promptInt", + c.interactiveTemplateFuncs.promptInt, + "Populate promptInt", + ) + flags.StringToStringVar( + &c.interactiveTemplateFuncs.promptString, + "promptString", + c.interactiveTemplateFuncs.promptString, + "Populate promptString", + ) +} + +func (c *Config) promptBoolInteractiveTemplateFunc(prompt string, args ...bool) bool { + if len(args) > 1 { + err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + panic(err) + } + + if valueStr, ok := c.interactiveTemplateFuncs.promptBool[prompt]; ok { + value, err := chezmoi.ParseBool(valueStr) + if err != nil { + panic(err) + } + return value + } + + value, err := c.promptBool(prompt, args...) + if err != nil { + panic(err) + } + return value +} + +func (c *Config) promptBoolOnceInteractiveTemplateFunc(m map[string]any, path any, prompt string, args ...bool) bool { + if len(args) > 1 { + err := fmt.Errorf("want 3 or 4 arguments, got %d", len(args)+2) + panic(err) + } + + nestedMap, lastKey, err := nestedMapAtPath(m, path) + if err != nil { + panic(err) + } + if !c.interactiveTemplateFuncs.forcePromptOnce { + if value, ok := nestedMap[lastKey]; ok { + switch value := value.(type) { + case bool: + return value + case string: + if boolValue, err := chezmoi.ParseBool(value); err == nil { + return boolValue + } + } + } + } + + return c.promptBoolInteractiveTemplateFunc(prompt, args...) +} + +func (c *Config) promptChoiceInteractiveTemplateFunc(prompt string, choices any, args ...string) string { + if len(args) > 1 { + err := fmt.Errorf("want 2 or 3 arguments, got %d", len(args)+2) + panic(err) + } + + if valueStr, ok := c.interactiveTemplateFuncs.promptChoice[prompt]; ok { + return valueStr + } + + choiceStrs, err := anyToStringSlice(choices) + if err != nil { + panic(err) + } + + value, err := c.promptChoice(prompt, choiceStrs, args...) + if err != nil { + panic(err) + } + return value +} + +func (c *Config) promptChoiceOnceInteractiveTemplateFunc( + m map[string]any, + path any, + prompt string, + choices any, + args ...string, +) string { + if len(args) > 1 { + err := fmt.Errorf("want 4 or 5 arguments, got %d", len(args)+4) + panic(err) + } + + nestedMap, lastKey, err := nestedMapAtPath(m, path) + if err != nil { + panic(err) + } + if !c.interactiveTemplateFuncs.forcePromptOnce { + if value, ok := nestedMap[lastKey]; ok { + if valueStr, ok := value.(string); ok { + return valueStr + } + } + } + + return c.promptChoiceInteractiveTemplateFunc(prompt, choices, args...) +} + +func (c *Config) promptIntInteractiveTemplateFunc(prompt string, args ...int64) int64 { + if len(args) > 1 { + err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + panic(err) + } + + if value, ok := c.interactiveTemplateFuncs.promptInt[prompt]; ok { + return int64(value) + } + + value, err := c.promptInt(prompt, args...) + if err != nil { + panic(err) + } + return value +} + +func (c *Config) promptIntOnceInteractiveTemplateFunc(m map[string]any, path any, prompt string, args ...int64) int64 { + if len(args) > 1 { + err := fmt.Errorf("want 2 or 3 arguments, got %d", len(args)+2) + panic(err) + } + + nestedMap, lastKey, err := nestedMapAtPath(m, path) + if err != nil { + panic(err) + } + if !c.interactiveTemplateFuncs.forcePromptOnce { + if value, ok := nestedMap[lastKey]; ok { + if intValue, ok := value.(int64); ok { + return intValue + } + } + } + + return c.promptIntInteractiveTemplateFunc(prompt, args...) +} + +func (c *Config) promptStringInteractiveTemplateFunc(prompt string, args ...string) string { + if len(args) > 1 { + err := fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + panic(err) + } + + if value, ok := c.interactiveTemplateFuncs.promptString[prompt]; ok { + return value + } + + value, err := c.promptString(prompt, args...) + if err != nil { + panic(err) + } + return value +} + +func (c *Config) promptStringOnceInteractiveTemplateFunc(m map[string]any, path any, prompt string, args ...string) string { + if len(args) > 1 { + err := fmt.Errorf("want 2 or 3 arguments, got %d", len(args)+2) + panic(err) + } + + nestedMap, lastKey, err := nestedMapAtPath(m, path) + if err != nil { + panic(err) + } + if !c.interactiveTemplateFuncs.forcePromptOnce { + if value, ok := nestedMap[lastKey]; ok { + if stringValue, ok := value.(string); ok { + return stringValue + } + } + } + + return c.promptStringInteractiveTemplateFunc(prompt, args...) +} + +func anyToString(v any) (string, error) { + switch v := v.(type) { + case []byte: + return string(v), nil + case string: + return v, nil + default: + return "", fmt.Errorf("%v: not a string", v) + } +} + +func anyToStringSlice(slice any) ([]string, error) { + switch slice := slice.(type) { + case []any: + result := make([]string, 0, len(slice)) + for _, elem := range slice { + elemStr, err := anyToString(elem) + if err != nil { + return nil, err + } + result = append(result, elemStr) + } + return result, nil + case []string: + return slice, nil + default: + return nil, fmt.Errorf("%v: not a slice", slice) + } +} diff --git a/internal/cmd/internaltestcmd.go b/internal/cmd/internaltestcmd.go index f536c3a4a4e..e88f620ccb0 100644 --- a/internal/cmd/internaltestcmd.go +++ b/internal/cmd/internaltestcmd.go @@ -1,7 +1,12 @@ package cmd import ( + "strconv" + "strings" + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) func (c *Config) newInternalTestCmd() *cobra.Command { @@ -11,16 +16,112 @@ func (c *Config) newInternalTestCmd() *cobra.Command { Hidden: true, } + internalTestPromptBoolCmd := &cobra.Command{ + Use: "prompt-bool", + Args: cobra.MinimumNArgs(1), + Short: "Run promptBool", + RunE: c.runInternalTestPromptBoolCmd, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), + } + internalTestCmd.AddCommand(internalTestPromptBoolCmd) + + internalTestPromptChoiceCmd := &cobra.Command{ + Use: "prompt-choice", + Args: cobra.MinimumNArgs(2), + Short: "Run promptChoice", + RunE: c.runInternalTestPromptChoiceCmd, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), + } + internalTestCmd.AddCommand(internalTestPromptChoiceCmd) + + internalTestPromptIntCmd := &cobra.Command{ + Use: "prompt-int", + Args: cobra.MinimumNArgs(1), + Short: "Run promptInt", + RunE: c.runInternalTestPromptIntCmd, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), + } + internalTestCmd.AddCommand(internalTestPromptIntCmd) + + internalTestPromptStringCmd := &cobra.Command{ + Use: "prompt-string", + Args: cobra.MinimumNArgs(1), + Short: "Run promptString", + RunE: c.runInternalTestPromptStringCmd, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), + } + internalTestCmd.AddCommand(internalTestPromptStringCmd) + internalTestReadPasswordCmd := &cobra.Command{ Use: "read-password", + Args: cobra.NoArgs, Short: "Read a password", RunE: c.runInternalTestReadPasswordCmd, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), } internalTestCmd.AddCommand(internalTestReadPasswordCmd) return internalTestCmd } +func (c *Config) runInternalTestPromptBoolCmd(cmd *cobra.Command, args []string) error { + boolArgs := make([]bool, 0, len(args)-1) + for _, arg := range args[1:] { + boolArg, err := chezmoi.ParseBool(arg) + if err != nil { + return err + } + boolArgs = append(boolArgs, boolArg) + } + value, err := c.promptBool(args[0], boolArgs...) + if err != nil { + return err + } + return c.writeOutputString(strconv.FormatBool(value) + "\n") +} + +func (c *Config) runInternalTestPromptChoiceCmd(cmd *cobra.Command, args []string) error { + value, err := c.promptChoice(args[0], strings.Split(args[1], ","), args[2:]...) + if err != nil { + return err + } + return c.writeOutputString(value + "\n") +} + +func (c *Config) runInternalTestPromptIntCmd(cmd *cobra.Command, args []string) error { + int64Args := make([]int64, 0, len(args)-1) + for _, arg := range args[1:] { + int64Arg, err := strconv.ParseInt(arg, 10, 64) + if err != nil { + return err + } + int64Args = append(int64Args, int64Arg) + } + value, err := c.promptInt(args[0], int64Args...) + if err != nil { + return err + } + return c.writeOutputString(strconv.FormatInt(value, 10) + "\n") +} + +func (c *Config) runInternalTestPromptStringCmd(cmd *cobra.Command, args []string) error { + value, err := c.promptString(args[0], args[1:]...) + if err != nil { + return err + } + return c.writeOutputString(value + "\n") +} + func (c *Config) runInternalTestReadPasswordCmd(cmd *cobra.Command, args []string) error { password, err := c.readPassword("Password? ") if err != nil { diff --git a/internal/cmd/keepassxctemplatefuncs.go b/internal/cmd/keepassxctemplatefuncs.go index 3b9033078e7..408e063c55a 100644 --- a/internal/cmd/keepassxctemplatefuncs.go +++ b/internal/cmd/keepassxctemplatefuncs.go @@ -5,13 +5,26 @@ import ( "bytes" "errors" "fmt" + "os" "os/exec" "regexp" + "slices" + "strconv" "strings" + "time" + "github.com/Netflix/go-expect" "github.com/coreos/go-semver/semver" "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +type keepassxcMode string + +const ( + keepassxcModeCachePassword keepassxcMode = "cache-password" + keepassxcModeOpen keepassxcMode = "open" ) type keepassxcAttributeCacheKey struct { @@ -20,20 +33,91 @@ type keepassxcAttributeCacheKey struct { } type keepassxcConfig struct { - Command string - Database chezmoi.AbsPath - Args []string - version *semver.Version - cache map[string]map[string]string - attributeCache map[keepassxcAttributeCacheKey]string - password string + Command string `json:"command" mapstructure:"command" yaml:"command"` + Database chezmoi.AbsPath `json:"database" mapstructure:"database" yaml:"database"` + Mode keepassxcMode `json:"mode" mapstructure:"mode" yaml:"mode"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + Prompt bool `json:"prompt" mapstructure:"prompt" yaml:"prompt"` + cmd *exec.Cmd + console *expect.Console + prompt string + cache map[string]map[string]string + attachmentCache 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} + keepassxcMinVersion = semver.Version{Major: 2, Minor: 7, Patch: 0} + + keepassxcEnterPasswordToUnlockDatabaseRx = regexp.MustCompile(`^Enter password to unlock .*: `) + keepassxcAnyResponseRx = regexp.MustCompile(`(?m)\A.*\r\n`) + keepassxcPairRx = regexp.MustCompile(`^([A-Z]\w*):\s*(.*)$`) + keepassxcPromptRx = regexp.MustCompile(`^.*> `) ) +func (c *Config) keepassxcAttachmentTemplateFunc(entry, name string) string { + if data, ok := c.Keepassxc.attachmentCache[entry][name]; ok { + return data + } + + switch c.Keepassxc.Mode { + case keepassxcModeCachePassword: + // In cache password mode use --stdout to read the attachment data directly. + output, err := c.keepassxcOutput("attachment-export", "--quiet", "--stdout", entry, name) + if err != nil { + panic(err) + } + return string(output) + case keepassxcModeOpen: + // In open mode write the attachment data to a temporary file. + tempDir, err := c.tempDir("chezmoi-keepassxc") + if err != nil { + panic(err) + } + tempFilename := tempDir.JoinString("attachment-" + strconv.FormatInt(time.Now().UnixNano(), 10)).String() + if _, err := c.keepassxcOutputOpen("attachment-export", "--quiet", entry, name, tempFilename); err != nil { + panic(err) + } + data, err := os.ReadFile(tempFilename) + if err != nil { + panic(err) + } + if err := os.Remove(tempFilename); err != nil { + panic(err) + } + return string(data) + default: + panic(fmt.Sprintf("%s: invalid mode", c.Keepassxc.Mode)) + } +} + +func (c *Config) keepassxcTemplateFunc(entry string) map[string]string { + if data, ok := c.Keepassxc.cache[entry]; ok { + return data + } + + command := "show" + args := []string{"--quiet", "--show-protected", entry} + output, err := c.keepassxcOutput("show", args...) + if err != nil { + panic(err) + } + + data, err := keepassxcParseOutput(output) + if err != nil { + // FIXME the error below should vary depending on the mode + panic(newParseCmdOutputError(command, args, output, err)) + } + + if c.Keepassxc.cache == nil { + c.Keepassxc.cache = make(map[string]map[string]string) + } + c.Keepassxc.cache[entry] = data + + return data +} + func (c *Config) keepassxcAttributeTemplateFunc(entry, attribute string) string { key := keepassxcAttributeCacheKey{ entry: entry, @@ -42,108 +126,217 @@ func (c *Config) keepassxcAttributeTemplateFunc(entry, attribute string) string if data, ok := c.Keepassxc.attributeCache[key]; ok { return data } - if c.Keepassxc.Database.Empty() { - 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.String(), entry) - output, err := c.runKeepassxcCLICommand(name, args) + + output, err := c.keepassxcOutput("show", entry, "--attributes", attribute, "--quiet", "--show-protected") if err != nil { - returnTemplateError(fmt.Errorf("%s: %w", shellQuoteCommand(name, args), err)) - return "" + panic(err) } - outputStr := strings.TrimSpace(string(output)) + + outputStr := string(bytes.TrimSpace(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 - } +// keepassxcOutput returns the output of command and args. +func (c *Config) keepassxcOutput(command string, args ...string) ([]byte, error) { if c.Keepassxc.Database.Empty() { - 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.String(), entry) - output, err := c.runKeepassxcCLICommand(name, args) - if err != nil { - returnTemplateError(fmt.Errorf("%s: %w", shellQuoteCommand(name, args), err)) - return nil + panic(errors.New("keepassxc.database not set")) } - data, err := parseKeyPassXCOutput(output) - if err != nil { - returnTemplateError(fmt.Errorf("%s: %w", shellQuoteCommand(name, args), err)) - return nil - } - if c.Keepassxc.cache == nil { - c.Keepassxc.cache = make(map[string]map[string]string) + + switch c.Keepassxc.Mode { + case keepassxcModeCachePassword: + return c.keepassxcOutputCachePassword(command, args...) + case keepassxcModeOpen: + return c.keepassxcOutputOpen(command, args...) + default: + panic(fmt.Sprintf("%s: invalid mode", c.Keepassxc.Mode)) } - c.Keepassxc.cache[entry] = data - return data } -func (c *Config) keepassxcVersion() *semver.Version { - if c.Keepassxc.version != nil { - return c.Keepassxc.version +// keepassxcOutputCachePassword returns the output of command and args, +// prompting the user for the password and caching it for later use. +func (c *Config) keepassxcOutputCachePassword(command string, args ...string) ([]byte, error) { + cmdArgs := []string{command} + cmdArgs = append(cmdArgs, c.Keepassxc.Args...) + cmdArgs = append(cmdArgs, c.Keepassxc.Database.String()) + cmdArgs = append(cmdArgs, args...) + cmd := exec.Command(c.Keepassxc.Command, cmdArgs...) //nolint:gosec + if c.Keepassxc.password == "" && c.Keepassxc.Prompt { + password, err := c.readPassword(fmt.Sprintf("Enter password to unlock %s: ", c.Keepassxc.Database)) + if err != nil { + return nil, err + } + c.Keepassxc.password = password } - 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: %w", shellQuoteCommand(name, args), err)) - return nil + if c.Keepassxc.password != "" { + cmd.Stdin = bytes.NewBufferString(c.Keepassxc.password + "\n") + } else { + cmd.Stdin = os.Stdin } - c.Keepassxc.version, err = semver.NewVersion(string(bytes.TrimSpace(output))) + cmd.Stderr = os.Stderr + + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) if err != nil { - returnTemplateError(fmt.Errorf("cannot parse version %s: %w", output, err)) - return nil + return nil, newCmdOutputError(cmd, output, err) } - return c.Keepassxc.version + return output, nil } -func (c *Config) runKeepassxcCLICommand(name string, args []string) ([]byte, error) { - if c.Keepassxc.password == "" { - password, err := c.readPassword(fmt.Sprintf("Insert password to unlock %s: ", c.Keepassxc.Database)) +// keepassxcOutputOpen returns the output of command and args using an +// interactive connection via keepassxc-cli open command's interactive console. +func (c *Config) keepassxcOutputOpen(command string, args ...string) ([]byte, error) { + // Create the console if it is not already created. + if c.Keepassxc.console == nil { + // Create the console. + console, err := expect.NewConsole() if err != nil { return nil, err } - c.Keepassxc.password = password + + // Start the keepassxc-cli open command. + cmdArgs := append(slices.Clone(c.Keepassxc.Args), "open", c.Keepassxc.Database.String()) + cmd := exec.Command(c.Keepassxc.Command, cmdArgs...) //nolint:gosec + env := os.Environ() + // Ensure prompt is in English. + env = append(env, "LANGUAGE=en") + // Reduce injection of terminal control characters. + env = slices.DeleteFunc(env, func(s string) bool { + return strings.HasPrefix(s, "TERM=") + }) + cmd.Env = env + cmd.Stdin = console.Tty() + cmd.Stdout = console.Tty() + cmd.Stderr = console.Tty() + if err := chezmoilog.LogCmdStart(c.logger, cmd); err != nil { + return nil, err + } + + if c.Keepassxc.Prompt { + // Expect the password prompt, e.g. "Enter password to unlock $HOME/Passwords.kdbx: ". + enterPasswordToUnlockPrompt, err := console.Expect( + expect.Regexp(keepassxcEnterPasswordToUnlockDatabaseRx), + expect.Regexp(keepassxcAnyResponseRx), + ) + if err != nil { + return nil, err + } + if !keepassxcEnterPasswordToUnlockDatabaseRx.MatchString(enterPasswordToUnlockPrompt) { + return nil, errors.New(strings.TrimSpace(enterPasswordToUnlockPrompt)) + } + + // Read the password from the user, if necessary. + var password string + if c.Keepassxc.password != "" { + password = c.Keepassxc.password + } else { + password, err = c.readPassword(enterPasswordToUnlockPrompt) + if err != nil { + return nil, err + } + } + + // Send the password. + if _, err := console.SendLine(password); err != nil { + return nil, err + } + + // Wait for the end of the password prompt. + if _, err := console.ExpectString("\r\n"); err != nil { + return nil, err + } + } + + // Read the prompt, e.g "Passwords> ", so we can expect it later. + output, err := console.Expect( + expect.Regexp(keepassxcPromptRx), + expect.Regexp(keepassxcAnyResponseRx), + ) + if err != nil { + return nil, err + } + if !keepassxcPromptRx.MatchString(output) { + return nil, errors.New(strings.TrimSpace(output)) + } + + c.Keepassxc.cmd = cmd + c.Keepassxc.console = console + c.Keepassxc.prompt = keepassxcPromptRx.FindString(output) + } + + // Build the command line. Strings with spaces and other non-word characters + // need to be quoted. + quotedArgs := make([]string, 0, len(args)) + for _, arg := range args { + quotedArgs = append(quotedArgs, maybeQuote(arg)) + } + line := strings.Join(append([]string{command}, quotedArgs...), " ") + + // Send the line. + if _, err := c.Keepassxc.console.SendLine(line); err != nil { + return nil, err + } + + // Read everything up to and including the prompt. + output, err := c.Keepassxc.console.ExpectString(c.Keepassxc.prompt) + if err != nil { + return nil, err + } + outputLines := strings.Split(output, "\r\n") + + // Trim the echoed command from the output, which is the first line. + if len(outputLines) > 0 { + outputLines = outputLines[1:] } - cmd := exec.Command(name, args...) - cmd.Stdin = bytes.NewBufferString(c.Keepassxc.password + "\n") - cmd.Stderr = c.stderr - return c.baseSystem.IdempotentCmdOutput(cmd) + + // Trim the prompt from the output, which is the last line. + if len(outputLines) > 0 { + outputLines = outputLines[:len(outputLines)-1] + } + + return []byte(strings.Join(outputLines, "\r\n")), nil } -func parseKeyPassXCOutput(output []byte) (map[string]string, error) { +// keepassxcParseOutput parses a list of key-value pairs. +func keepassxcParseOutput(output []byte) (map[string]string, error) { data := make(map[string]string) s := bufio.NewScanner(bytes.NewReader(output)) + var key string 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()) + switch match := keepassxcPairRx.FindStringSubmatch(s.Text()); { + case match != nil: + key = match[1] + data[key] = match[2] + case key != "": + data[key] += "\n" + s.Text() } - data[match[1]] = match[2] } - return data, s.Err() + if err := s.Err(); err != nil { + return nil, err + } + return data, nil +} + +// keepassxcClose closes any open connection to keepassxc-cli. +func (c *Config) keepassxcClose() error { + // FIXME should we wait for EOF somewhere? + if c.Keepassxc.console == nil { + return nil + } + if _, err := c.Keepassxc.console.SendLine("exit"); err != nil { + return err + } + if _, err := c.Keepassxc.console.ExpectString("exit\r\n"); err != nil { + return err + } + if err := chezmoilog.LogCmdWait(c.logger, c.Keepassxc.cmd); err != nil { + return err + } + if err := c.Keepassxc.console.Close(); err != nil { + return err + } + return nil } diff --git a/internal/cmd/keepassxctemplatefuncs_test.go b/internal/cmd/keepassxctemplatefuncs_test.go new file mode 100644 index 00000000000..cc7aa163065 --- /dev/null +++ b/internal/cmd/keepassxctemplatefuncs_test.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoitest" +) + +func TestKeepassxcParseOutput(t *testing.T) { + for i, tc := range []struct { + output []byte + expected map[string]string + }{ + { + expected: map[string]string{}, + }, + { + output: []byte(chezmoitest.JoinLines( + "Title: test", + "UserName: test", + "Password: test", + "URL:", + "Notes: account: 123456789", + "2021-11-27 [expires: 2023-02-25]", + "main = false", + )), + expected: map[string]string{ + "Title": "test", + "UserName": "test", + "Password": "test", + "URL": "", + "Notes": strings.Join([]string{ + "account: 123456789", + "2021-11-27 [expires: 2023-02-25]", + "main = false", + }, "\n"), + }, + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + actual, err := keepassxcParseOutput(tc.output) + assert.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func TestKeepassxcTemplateFuncs(t *testing.T) { + // Find the path to keepassxc-cli command. + command, err := exec.LookPath("keepassxc-cli") + if err != nil { + t.Skip("keepassxc-cli not found in $PATH") + } + assert.NoError(t, err) + + tempDir := t.TempDir() + + // The following test data includes spaces and slashes to test quoting. + database := filepath.Join(tempDir, "KeePassXC Passwords.kdbx") + databasePassword := "test / database / password" + groupName := "test group" + entryName := groupName + "/test entry" + entryUsername := "test / username" + entryPassword := "test / password" + attachmentName := "test / attachment name" + attachmentData := "test / attachment data" + + // Create a KeePassXC database. + dbCreateCmd := exec.Command(command, "db-create", "--set-password", database) + dbCreateCmd.Stdin = strings.NewReader(chezmoitest.JoinLines( + databasePassword, + databasePassword, + )) + dbCreateCmd.Stdout = os.Stdout + dbCreateCmd.Stderr = os.Stderr + assert.NoError(t, dbCreateCmd.Run()) + + // Create a group in the database. + mkdirCmd := exec.Command(command, "mkdir", database, groupName) + mkdirCmd.Stdin = strings.NewReader(chezmoitest.JoinLines( + databasePassword, + )) + mkdirCmd.Stdout = os.Stdout + mkdirCmd.Stderr = os.Stderr + assert.NoError(t, mkdirCmd.Run()) + + // Create an entry in the database. + addCmd := exec.Command(command, "add", database, entryName, "--username", entryUsername, "--password-prompt") + addCmd.Stdin = strings.NewReader(chezmoitest.JoinLines( + databasePassword, + entryPassword, + )) + addCmd.Stdout = os.Stdout + addCmd.Stderr = os.Stderr + assert.NoError(t, addCmd.Run()) + + // Import an attachment to the entry in the database. + importFile := filepath.Join(tempDir, "import file") + assert.NoError(t, os.WriteFile(importFile, []byte(attachmentData), 0o666)) + attachmentImportCmd := exec.Command(command, "attachment-import", database, entryName, attachmentName, importFile) + attachmentImportCmd.Stdin = strings.NewReader(chezmoitest.JoinLines( + databasePassword, + )) + attachmentImportCmd.Stdout = os.Stdout + attachmentImportCmd.Stderr = os.Stderr + assert.NoError(t, attachmentImportCmd.Run()) + + for _, mode := range []keepassxcMode{ + keepassxcModeCachePassword, + keepassxcModeOpen, + } { + t.Run(string(mode), func(t *testing.T) { + t.Run("correct_password", func(t *testing.T) { + config := newTestConfig(t, vfs.OSFS) + defer config.keepassxcClose() + config.Keepassxc.Database = chezmoi.NewAbsPath(database) + config.Keepassxc.Mode = mode + config.Keepassxc.Prompt = true + config.Keepassxc.password = databasePassword + assert.Equal(t, entryPassword, config.keepassxcTemplateFunc(entryName)["Password"]) + assert.Equal(t, entryUsername, config.keepassxcAttributeTemplateFunc(entryName, "UserName")) + assert.Equal(t, attachmentData, config.keepassxcAttachmentTemplateFunc(entryName, attachmentName)) + }) + + t.Run("incorrect_password", func(t *testing.T) { + config := newTestConfig(t, vfs.OSFS) + defer config.keepassxcClose() + config.Keepassxc.Database = chezmoi.NewAbsPath(database) + config.Keepassxc.Mode = mode + config.Keepassxc.Prompt = true + config.Keepassxc.password = "incorrect-" + databasePassword + assert.Panics(t, func() { + config.keepassxcTemplateFunc(entryName) + }) + assert.Panics(t, func() { + config.keepassxcAttributeTemplateFunc(entryName, "UserName") + }) + assert.Panics(t, func() { + config.keepassxcAttachmentTemplateFunc(entryName, attachmentName) + }) + }) + + t.Run("incorrect_database", func(t *testing.T) { + config := newTestConfig(t, vfs.OSFS) + defer config.keepassxcClose() + config.Keepassxc.Database = chezmoi.NewAbsPath(filepath.Join(tempDir, "Non-existent database.kdbx")) + config.Keepassxc.Mode = mode + config.Keepassxc.Prompt = true + config.Keepassxc.password = databasePassword + assert.Panics(t, func() { + config.keepassxcTemplateFunc(entryName) + }) + assert.Panics(t, func() { + config.keepassxcAttributeTemplateFunc(entryName, "UserName") + }) + assert.Panics(t, func() { + config.keepassxcAttachmentTemplateFunc(entryName, attachmentName) + }) + }) + }) + } +} diff --git a/internal/cmd/keepertemplatefuncs.go b/internal/cmd/keepertemplatefuncs.go new file mode 100644 index 00000000000..00892cd1e1c --- /dev/null +++ b/internal/cmd/keepertemplatefuncs.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "strings" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +type keeperConfig struct { + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + outputCache map[string][]byte +} + +func (c *Config) keeperTemplateFunc(record string) map[string]any { + output, err := c.keeperOutput([]string{"get", "--format=json", record}) + if err != nil { + panic(err) + } + var result map[string]any + if err := json.Unmarshal(output, &result); err != nil { + panic(err) + } + return result +} + +func (c *Config) keeperDataFieldsTemplateFunc(record string) map[string]any { + output, err := c.keeperOutput([]string{"get", "--format=json", record}) + if err != nil { + panic(err) + } + var data struct { + Data struct { + Fields []struct { + Type string `json:"type"` + Value any `json:"value"` + } `json:"fields"` + } `json:"data"` + } + if err := json.Unmarshal(output, &data); err != nil { + panic(err) + } + result := make(map[string]any) + for _, field := range data.Data.Fields { + result[field.Type] = field.Value + } + return result +} + +func (c *Config) keeperFindPasswordTemplateFunc(record string) string { + output, err := c.keeperOutput([]string{"find-password", record}) + if err != nil { + panic(err) + } + return string(bytes.TrimSpace(output)) +} + +func (c *Config) keeperOutput(args []string) ([]byte, error) { + key := strings.Join(args, "\x00") + if data, ok := c.Keeper.outputCache[key]; ok { + return data, nil + } + + name := c.Keeper.Command + args = append(args, c.Keeper.Args...) + cmd := exec.Command(name, args...) + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) + if err != nil { + return nil, newCmdOutputError(cmd, output, err) + } + + if c.Keeper.outputCache == nil { + c.Keeper.outputCache = make(map[string][]byte) + } + c.Keeper.outputCache[key] = output + return output, nil +} diff --git a/internal/cmd/keyringtemplatefuncs.go b/internal/cmd/keyringtemplatefuncs.go index 0d030a36d13..d023b0fe8ba 100644 --- a/internal/cmd/keyringtemplatefuncs.go +++ b/internal/cmd/keyringtemplatefuncs.go @@ -1,3 +1,5 @@ +//go:build !freebsd || (freebsd && cgo) + package cmd import ( @@ -25,8 +27,7 @@ func (c *Config) keyringTemplateFunc(service, user string) string { } password, err := keyring.Get(service, user) if err != nil { - returnTemplateError(fmt.Errorf("%s %s: %w", service, user, err)) - return "" + panic(fmt.Errorf("%s %s: %w", service, user, err)) } if c.keyring.cache == nil { diff --git a/internal/cmd/keyringtemplatefuncs_freebsdnocgo.go b/internal/cmd/keyringtemplatefuncs_freebsdnocgo.go new file mode 100644 index 00000000000..a4f00300d6f --- /dev/null +++ b/internal/cmd/keyringtemplatefuncs_freebsdnocgo.go @@ -0,0 +1,9 @@ +//go:build freebsd && !cgo + +package cmd + +type keyringData struct{} + +func (c *Config) keyringTemplateFunc(service, user string) string { + return "" +} diff --git a/internal/cmd/lastpasstemplatefuncs.go b/internal/cmd/lastpasstemplatefuncs.go index 20ee6011f29..599c3647780 100644 --- a/internal/cmd/lastpasstemplatefuncs.go +++ b/internal/cmd/lastpasstemplatefuncs.go @@ -5,12 +5,15 @@ import ( "bytes" "encoding/json" "fmt" + "os" "os/exec" "regexp" "strings" "unicode" "github.com/coreos/go-semver/semver" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) var ( @@ -24,85 +27,69 @@ var ( ) type lastpassConfig struct { - Command string - versionOK bool - cache map[string][]map[string]interface{} + Command string `json:"command" mapstructure:"command" yaml:"command"` + cache map[string][]map[string]any } -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) +func (c *Config) lastpassTemplateFunc(id string) []map[string]any { + data, err := c.lastpassData(id) if err != nil { - return nil, err + panic(err) } - return output, nil + for _, d := range data { + if note, ok := d["note"].(string); ok { + d["note"], err = lastpassParseNote(note) + if err != nil { + panic(err) + } + } + } + return data } -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 +func (c *Config) lastpassRawTemplateFunc(id string) []map[string]any { + data, err := c.lastpassData(id) + if err != nil { + panic(err) } + return data +} +func (c *Config) lastpassData(id string) ([]map[string]any, error) { if data, ok := c.Lastpass.cache[id]; ok { - return data + return data, nil } output, err := c.lastpassOutput("show", "--json", id) if err != nil { - returnTemplateError(err) - return nil + return nil, err } - var data []map[string]interface{} + var data []map[string]any if err := json.Unmarshal(output, &data); err != nil { - returnTemplateError(fmt.Errorf("%s: parse error: %w", output, err)) - return nil + return nil, fmt.Errorf("%s: parse error: %w", output, err) } if c.Lastpass.cache == nil { - c.Lastpass.cache = make(map[string][]map[string]interface{}) + c.Lastpass.cache = make(map[string][]map[string]any) } c.Lastpass.cache[id] = data - return data + return data, nil } -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])) +func (c *Config) lastpassOutput(args ...string) ([]byte, error) { + name := c.Lastpass.Command + cmd := exec.Command(name, args...) + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) if err != nil { - return err - } - if version.LessThan(lastpassMinVersion) { - return fmt.Errorf("version %s found, need version %s or later", version, lastpassMinVersion) + return nil, err } - return nil + return output, nil } -func lastpassParseNote(note string) map[string]string { +func lastpassParseNote(note string) (map[string]string, error) { result := make(map[string]string) s := bufio.NewScanner(bytes.NewBufferString(note)) key := "" @@ -119,8 +106,7 @@ func lastpassParseNote(note string) map[string]string { } } if err := s.Err(); err != nil { - returnTemplateError(err) - return nil + return nil, err } - return result + return result, nil } diff --git a/internal/cmd/lastpasstemplatefuncs_test.go b/internal/cmd/lastpasstemplatefuncs_test.go index 86ea03def97..5dc0095efe1 100644 --- a/internal/cmd/lastpasstemplatefuncs_test.go +++ b/internal/cmd/lastpasstemplatefuncs_test.go @@ -1,15 +1,16 @@ package cmd import ( + "strconv" "testing" - "github.com/stretchr/testify/assert" + "github.com/alecthomas/assert/v2" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) -func Test_lastpassParseNote(t *testing.T) { - for _, tc := range []struct { +func TestLastpassParseNote(t *testing.T) { + for i, tc := range []struct { note string expected map[string]string }{ @@ -20,9 +21,15 @@ func Test_lastpassParseNote(t *testing.T) { }, }, { - note: "Foo:bar\nbaz\n", + note: chezmoitest.JoinLines( + "Foo:bar", + "baz", + ), expected: map[string]string{ - "foo": "bar\nbaz\n", + "foo": chezmoitest.JoinLines( + "bar", + "baz", + ), }, }, { @@ -52,6 +59,10 @@ func Test_lastpassParseNote(t *testing.T) { }, }, } { - assert.Equal(t, tc.expected, lastpassParseNote(tc.note)) + t.Run(strconv.Itoa(i), func(t *testing.T) { + actual, err := lastpassParseNote(tc.note) + assert.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) } } diff --git a/internal/cmd/lazyscryptidentity.go b/internal/cmd/lazyscryptidentity.go new file mode 100644 index 00000000000..41d787aa516 --- /dev/null +++ b/internal/cmd/lazyscryptidentity.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "errors" + "fmt" + + "filippo.io/age" +) + +// This is copied from https://github.com/FiloSottile/age/blob/6ad4560f4afc3fe46b6cda0bc568e50b89a22e4c/cmd/age/encrypted_keys.go#L15-L51 + +// LazyScryptIdentity is an age.Identity that requests a passphrase only if it +// encounters an scrypt stanza. After obtaining a passphrase, it delegates to +// ScryptIdentity. +type LazyScryptIdentity struct { + Passphrase func() (string, error) +} + +var _ age.Identity = &LazyScryptIdentity{} + +func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) { + for _, s := range stanzas { + if s.Type == "scrypt" && len(stanzas) != 1 { + return nil, errors.New("an scrypt recipient must be the only one") + } + } + if len(stanzas) != 1 || stanzas[0].Type != "scrypt" { + return nil, age.ErrIncorrectIdentity + } + pass, err := i.Passphrase() + if err != nil { + return nil, fmt.Errorf("could not read passphrase: %w", err) + } + ii, err := age.NewScryptIdentity(pass) + if err != nil { + return nil, err + } + fileKey, err = ii.Unwrap(stanzas) + if errors.Is(err, age.ErrIncorrectIdentity) { + // ScryptIdentity returns ErrIncorrectIdentity for an incorrect + // passphrase, which would lead Decrypt to returning "no identity + // matched any recipient". That makes sense in the API, where there + // might be multiple configured ScryptIdentity. Since in cmd/age there + // can be only one, return a better error message. + return nil, errors.New("incorrect passphrase") + } + return fileKey, err +} diff --git a/internal/cmd/lazywriter.go b/internal/cmd/lazywriter.go new file mode 100644 index 00000000000..e7b63629734 --- /dev/null +++ b/internal/cmd/lazywriter.go @@ -0,0 +1,34 @@ +package cmd + +import "io" + +// A lazyWriter only opens its destination on first write. +type lazyWriter struct { + openFunc func() (io.WriteCloser, error) + writeCloser io.WriteCloser +} + +func newLazyWriter(openFunc func() (io.WriteCloser, error)) *lazyWriter { + return &lazyWriter{ + openFunc: openFunc, + } +} + +func (w *lazyWriter) Close() error { + if w.writeCloser == nil { + return nil + } + return w.writeCloser.Close() +} + +func (w *lazyWriter) Write(p []byte) (int, error) { + if w.writeCloser == nil { + writeCloser, err := w.openFunc() + w.openFunc = nil + if err != nil { + return 0, err + } + w.writeCloser = writeCloser + } + return w.writeCloser.Write(p) +} diff --git a/internal/cmd/licensecmd.go b/internal/cmd/licensecmd.go new file mode 100644 index 00000000000..dcea7e3723e --- /dev/null +++ b/internal/cmd/licensecmd.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "bytes" + + "github.com/charmbracelet/glamour" + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/v2/assets/chezmoi.io/docs" +) + +func (c *Config) newLicenseCmd() *cobra.Command { + licenseCmd := &cobra.Command{ + Use: "license", + Short: "Print license", + Long: mustLongHelp("license"), + Example: example("license"), + Args: cobra.NoArgs, + RunE: c.runLicenseCmd, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), + } + + return licenseCmd +} + +func (c *Config) runLicenseCmd(cmd *cobra.Command, args []string) error { + renderer, err := glamour.NewTermRenderer( + glamour.WithStyles(glamour.ASCIIStyleConfig), + glamour.WithWordWrap(80), + ) + if err != nil { + return err + } + + licenseMarkdown := bytes.TrimPrefix(docs.License, []byte("# License\n\n")) + license, err := renderer.RenderBytes(licenseMarkdown) + if err != nil { + return err + } + + return c.writeOutput(license) +} diff --git a/internal/cmd/mackupcmd_darwin.go b/internal/cmd/mackupcmd_darwin.go new file mode 100644 index 00000000000..0e307b66857 --- /dev/null +++ b/internal/cmd/mackupcmd_darwin.go @@ -0,0 +1,179 @@ +package cmd + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io/fs" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +var ( + mackupCommentRx = regexp.MustCompile(`\A#.*\z`) + mackupKeyValueRx = regexp.MustCompile(`\A(\w+)\s*=\s*(.*)\z`) + mackupSectionRx = regexp.MustCompile(`\A\[(.*)\]\z`) +) + +type mackupApplicationApplicationConfig struct { + Name string +} + +type mackupApplicationConfig struct { + Application mackupApplicationApplicationConfig + ConfigurationFiles []chezmoi.RelPath + XDGConfigurationFiles []chezmoi.RelPath +} + +func (c *Config) newMackupCmd() *cobra.Command { + mackupCmd := &cobra.Command{ + Use: "mackup", + Short: "Interact with Mackup", + Hidden: true, + } + + mackupAddCmd := &cobra.Command{ + Use: "add application...", + Short: "Add an application's configuration from its Mackup configuration", + Args: cobra.MinimumNArgs(1), + RunE: c.makeRunEWithSourceState(c.runMackupAddCmd), + Annotations: newAnnotations( + createSourceDirectoryIfNeeded, + modifiesSourceDirectory, + persistentStateModeReadWrite, + requiresSourceDirectory, + ), + } + mackupAddCmd.Flags().Var(&c.Add.Secrets, "secrets", "Scan for secrets when adding unencrypted files") + mackupCmd.AddCommand(mackupAddCmd) + + return mackupCmd +} + +func (c *Config) runMackupAddCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { + mackupApplicationsDir, err := c.mackupApplicationsDir() + if err != nil { + return err + } + + mackupDirAbsPath := c.homeDirAbsPath.JoinString(".mackup") + var addArgs []string + for _, arg := range args { + configRelPath := chezmoi.NewRelPath(arg + ".cfg") + data, err := c.baseSystem.ReadFile(mackupDirAbsPath.Join(configRelPath)) + if errors.Is(err, fs.ErrNotExist) { + data, err = c.baseSystem.ReadFile(mackupApplicationsDir.Join(configRelPath)) + } + if err != nil { + return err + } + config, err := parseMackupApplication(data) + if err != nil { + return err + } + for _, filename := range config.ConfigurationFiles { + addArg := c.DestDirAbsPath.Join(filename) + addArgs = append(addArgs, addArg.String()) + } + configHomeAbsPath := chezmoi.NewAbsPath(c.bds.ConfigHome) + for _, filename := range config.XDGConfigurationFiles { + addArg := configHomeAbsPath.Join(filename) + addArgs = append(addArgs, addArg.String()) + } + } + + destAbsPathInfos, err := c.destAbsPathInfos(sourceState, addArgs, destAbsPathInfosOptions{ + follow: c.Add.follow, + ignoreNotExist: true, + onIgnoreFunc: c.defaultOnIgnoreFunc, + recursive: c.Add.recursive, + }) + if err != nil { + return err + } + + return sourceState.Add( + c.sourceSystem, + c.persistentState, + c.destSystem, + destAbsPathInfos, + &chezmoi.AddOptions{ + Errorf: c.errorf, + Filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + OnIgnoreFunc: c.defaultOnIgnoreFunc, + PreAddFunc: c.defaultPreAddFunc, + ReplaceFunc: c.defaultReplaceFunc, + }, + ) +} + +func (c *Config) mackupApplicationsDir() (chezmoi.AbsPath, error) { + mackupBinaryPath, err := chezmoi.LookPath("mackup") + if err != nil { + return chezmoi.EmptyAbsPath, err + } + mackupBinaryPathResolved, err := filepath.EvalSymlinks(mackupBinaryPath) + if err != nil { + return chezmoi.EmptyAbsPath, err + } + mackupBinaryAbsPath := chezmoi.NewAbsPath(mackupBinaryPathResolved) + + libDirAbsPath := mackupBinaryAbsPath.Dir().Dir().JoinString("lib") + dirEntries, err := c.baseSystem.ReadDir(libDirAbsPath) + if err != nil { + return chezmoi.EmptyAbsPath, err + } + + for _, dirEntry := range dirEntries { + if !dirEntry.IsDir() || !strings.HasPrefix(dirEntry.Name(), "python") { + continue + } + mackupApplicationsDirAbsPath := libDirAbsPath.JoinString(dirEntry.Name(), "site-packages", "mackup", "applications") + if fileInfo, err := c.baseSystem.Stat(mackupApplicationsDirAbsPath); err == nil && fileInfo.IsDir() { + return mackupApplicationsDirAbsPath, nil + } + } + + return chezmoi.EmptyAbsPath, fmt.Errorf("%s: mackup application directory not found", libDirAbsPath) +} + +func parseMackupApplication(data []byte) (mackupApplicationConfig, error) { + var config mackupApplicationConfig + var section string + s := bufio.NewScanner(bytes.NewReader(data)) + for s.Scan() { + text := s.Text() + if mackupCommentRx.MatchString(text) { + continue + } + if m := mackupSectionRx.FindStringSubmatch(s.Text()); m != nil { + section = m[1] + continue + } + text = strings.TrimSpace(text) + if text == "" { + continue + } + //nolint:gocritic + switch section { + case "application": + if m := mackupKeyValueRx.FindStringSubmatch(text); m != nil { + switch m[1] { + case "name": + config.Application.Name = m[2] + } + } + case "configuration_files": + config.ConfigurationFiles = append(config.ConfigurationFiles, chezmoi.NewRelPath(text)) + case "xdg_configuration_files": + config.XDGConfigurationFiles = append(config.XDGConfigurationFiles, chezmoi.NewRelPath(text)) + } + } + return config, s.Err() +} diff --git a/internal/cmd/mackupcmd_nodarwin.go b/internal/cmd/mackupcmd_nodarwin.go new file mode 100644 index 00000000000..7cb6ebec4d4 --- /dev/null +++ b/internal/cmd/mackupcmd_nodarwin.go @@ -0,0 +1,11 @@ +//go:build !darwin + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func (c *Config) newMackupCmd() *cobra.Command { + return nil +} diff --git a/internal/cmd/mackupcmd_test_darwin.go b/internal/cmd/mackupcmd_test_darwin.go new file mode 100644 index 00000000000..9c441410caa --- /dev/null +++ b/internal/cmd/mackupcmd_test_darwin.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoitest" +) + +func TestParseMackupApplication(t *testing.T) { + for _, tc := range []struct { + name string + lines []string + expected mackupApplicationConfig + }{ + { + name: "curl.cfg", + lines: []string{ + "[application]", + "name = Curl", + "", + "[configuration_files]", + ".netrc", + ".curlrc", + }, + expected: mackupApplicationConfig{ + Application: mackupApplicationApplicationConfig{ + Name: "Curl", + }, + ConfigurationFiles: []chezmoi.RelPath{ + chezmoi.NewRelPath(".netrc"), + chezmoi.NewRelPath(".curlrc"), + }, + }, + }, + { + name: "vscode.cfg", + lines: []string{ + "[application]", + "name = Visual Studio Code", + "", + "[configuration_files]", + "Library/Application Support/Code/User/snippets", + "Library/Application Support/Code/User/keybindings.json", + "Library/Application Support/Code/User/settings.json", + "", + "[xdg_configuration_files]", + "Code/User/snippets", + "Code/User/keybindings.json", + "Code/User/settings.json", + }, + expected: mackupApplicationConfig{ + Application: mackupApplicationApplicationConfig{ + Name: "Visual Studio Code", + }, + ConfigurationFiles: []chezmoi.RelPath{ + chezmoi.NewRelPath("Library/Application Support/Code/User/snippets"), + chezmoi.NewRelPath("Library/Application Support/Code/User/keybindings.json"), + chezmoi.NewRelPath("Library/Application Support/Code/User/settings.json"), + }, + XDGConfigurationFiles: []chezmoi.RelPath{ + chezmoi.NewRelPath("Code/User/snippets"), + chezmoi.NewRelPath("Code/User/keybindings.json"), + chezmoi.NewRelPath("Code/User/settings.json"), + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actual, err := parseMackupApplication([]byte(chezmoitest.JoinLines(tc.lines...))) + assert.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/internal/cmd/main_test.go b/internal/cmd/main_test.go index 59a0d0c525b..3f5bef893b9 100644 --- a/internal/cmd/main_test.go +++ b/internal/cmd/main_test.go @@ -6,10 +6,10 @@ import ( "errors" "fmt" "io/fs" + "net" "net/http" "net/http/httptest" "os" - "os/exec" "path" "path/filepath" "regexp" @@ -19,37 +19,23 @@ import ( "testing" "time" + "github.com/rogpeppe/go-internal/imports" "github.com/rogpeppe/go-internal/testscript" - "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" + "github.com/twpayne/chezmoi/v2/internal/chezmoi" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" "github.com/twpayne/chezmoi/v2/internal/cmd" ) -// FIXME remove the following when -// https://github.com/rogpeppe/go-internal/pull/147 is merged. -const ( - goosList = "aix android darwin dragonfly freebsd hurd illumos ios js linux nacl netbsd openbsd plan9 solaris windows zos " - goarchList = "386 amd64 amd64p32 arm armbe arm64 arm64be loong64 mips mipsle mips64 mips64le mips64p32 mips64p32le ppc ppc64 ppc64le riscv riscv64 s390 s390x sparc sparc64 wasm " -) - var ( - KnownOS = make(map[string]bool) - KnownArch = make(map[string]bool) + envConditionRx = regexp.MustCompile(`\Aenv:(\w+)\z`) + envVarRx = regexp.MustCompile(`\$\w+`) + lookupRx = regexp.MustCompile(`\Alookup:(.*)\z`) + umaskConditionRx = regexp.MustCompile(`\Aumask:([0-7]{3})\z`) ) -func init() { - for _, v := range strings.Fields(goosList) { - KnownOS[v] = true - } - for _, v := range strings.Fields(goarchList) { - KnownArch[v] = true - } -} - -var umaskConditionRx = regexp.MustCompile(`\Aumask:([0-7]{3})\z`) - func TestMain(m *testing.M) { os.Exit(testscript.RunMain(m, map[string]func() int{ "chezmoi": func() int { @@ -71,8 +57,11 @@ func TestScript(t *testing.T) { "chhome": cmdChHome, "cmpmod": cmdCmpMod, "edit": cmdEdit, + "expandenv": cmdExpandEnv, "httpd": cmdHTTPD, + "isdir": cmdIsDir, "issymlink": cmdIsSymlink, + "lexists": cmdLExists, "mkfile": cmdMkFile, "mkageconfig": cmdMkAgeConfig, "mkgitconfig": cmdMkGitConfig, @@ -89,13 +78,22 @@ func TestScript(t *testing.T) { if result, valid := goosCondition(cond); valid { return result, nil } + if m := envConditionRx.FindStringSubmatch(cond); m != nil { + return os.Getenv(m[1]) != "", nil + } + if m := lookupRx.FindStringSubmatch(cond); m != nil { + _, err := net.LookupIP(m[1]) + return err == nil, nil + } if m := umaskConditionRx.FindStringSubmatch(cond); m != nil { umask, _ := strconv.ParseInt(m[1], 8, 64) return chezmoitest.Umask == fs.FileMode(umask), nil } return false, fmt.Errorf("%s: unknown condition", cond) }, - Setup: setup, + RequireExplicitExec: true, + RequireUniqueNames: true, + Setup: setup, }) } @@ -129,7 +127,7 @@ func cmdChHome(ts *testscript.TestScript, neg bool, args []string) { chezmoiConfigDir = path.Join(homeDir, ".config", "chezmoi") chezmoiSourceDir = path.Join(homeDir, ".local", "share", "chezmoi") ) - ts.Check(os.MkdirAll(homeDir, 0o777)) + ts.Check(os.MkdirAll(homeDir, fs.ModePerm)) ts.Setenv("HOME", homeDir) ts.Setenv("CHEZMOICONFIGDIR", chezmoiConfigDir) ts.Setenv("CHEZMOISOURCEDIR", chezmoiSourceDir) @@ -159,7 +157,8 @@ func cmdCmpMod(ts *testscript.TestScript, neg bool, args []string) { ts.Fatalf("%s unexpectedly has mode %03o", args[1], fileInfo.Mode().Perm()) } if !neg && !equal { - ts.Fatalf("%s has mode %03o, expected %03o", args[1], fileInfo.Mode().Perm(), fs.FileMode(mode64)&^chezmoitest.Umask) + format := "%s has mode %03o, expected %03o" + ts.Fatalf(format, args[1], fileInfo.Mode().Perm(), fs.FileMode(mode64)&^chezmoitest.Umask) } } @@ -181,6 +180,32 @@ func cmdEdit(ts *testscript.TestScript, neg bool, args []string) { } } +// cmdExpandEnv expands environment variables in the given paths. +func cmdExpandEnv(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! expandenv") + } + if len(args) == 0 { + ts.Fatalf("usage: expandenv paths...") + } + for _, arg := range args { + filename := ts.MkAbs(arg) + data, err := os.ReadFile(filename) + if err != nil { + ts.Fatalf("%s: %v", filename, err) + } + data = envVarRx.ReplaceAllFunc(data, func(key []byte) []byte { + if value := ts.Getenv(string(bytes.TrimPrefix(key, []byte{'$'}))); value != "" { + return []byte(value) + } + return key + }) + if err := os.WriteFile(filename, data, 0o666); err != nil { + ts.Fatalf("%s: %v", filename, err) + } + } +} + // cmdHTTPD starts an HTTP server serving files from the given directory and // sets the HTTPD_URL environment variable to the URL of the server. func cmdHTTPD(ts *testscript.TestScript, neg bool, args []string) { @@ -195,7 +220,24 @@ func cmdHTTPD(ts *testscript.TestScript, neg bool, args []string) { ts.Setenv("HTTPD_URL", server.URL) } -// cmdIsSymlink returns true if all of its arguments are symlinks. +// cmdIsDir succeeds if all of its arguments are directories. +func cmdIsDir(ts *testscript.TestScript, neg bool, args []string) { + for _, arg := range args { + filename := ts.MkAbs(arg) + fileInfo, err := os.Lstat(filename) + if err != nil { + ts.Fatalf("%s: %v", arg, err) + } + switch isDir := fileInfo.IsDir(); { + case isDir && neg: + ts.Fatalf("%s is a directory", arg) + case !isDir && !neg: + ts.Fatalf("%s is not a directory", arg) + } + } +} + +// cmdIsSymlink succeeds if all of its arguments are symlinks. func cmdIsSymlink(ts *testscript.TestScript, neg bool, args []string) { for _, arg := range args { filename := ts.MkAbs(arg) @@ -212,6 +254,23 @@ func cmdIsSymlink(ts *testscript.TestScript, neg bool, args []string) { } } +// cmdLExists succeeds if all if its arguments exist, without following symlinks. +func cmdLExists(ts *testscript.TestScript, neg bool, args []string) { + if len(args) == 0 { + ts.Fatalf("usage: exists file...") + } + + for _, arg := range args { + filename := ts.MkAbs(arg) + switch _, err := os.Lstat(filename); { + case err == nil && neg: + ts.Fatalf("%s unexpectedly exists", filename) + case errors.Is(err, fs.ErrNotExist) && !neg: + ts.Fatalf("%s does not exist", filename) + } + } +} + // cmdMkFile creates empty files. func cmdMkFile(ts *testscript.TestScript, neg bool, args []string) { if neg { @@ -235,7 +294,7 @@ func cmdMkFile(ts *testscript.TestScript, neg bool, args []string) { case !errors.Is(err, fs.ErrNotExist): ts.Fatalf("%s: %v", arg, err) } - if err := os.WriteFile(filename, nil, perm); err != nil { + if err := writeNewFile(filename, nil, perm); err != nil { ts.Fatalf("%s: %v", arg, err) } } @@ -251,12 +310,12 @@ func cmdMkAgeConfig(ts *testscript.TestScript, neg bool, args []string) { } symmetric := len(args) == 1 && args[0] == "-symmetric" homeDir := ts.Getenv("HOME") - ts.Check(os.MkdirAll(homeDir, 0o777)) + ts.Check(os.MkdirAll(homeDir, fs.ModePerm)) identityFile := filepath.Join(homeDir, "key.txt") - recipient, err := chezmoitest.AgeGenerateKey(ts.MkAbs(identityFile)) + recipient, err := chezmoitest.AgeGenerateKey("age", ts.MkAbs(identityFile)) ts.Check(err) configFile := filepath.Join(homeDir, ".config", "chezmoi", "chezmoi.toml") - ts.Check(os.MkdirAll(filepath.Dir(configFile), 0o777)) + ts.Check(os.MkdirAll(filepath.Dir(configFile), fs.ModePerm)) lines := []string{ `encryption = "age"`, `[age]`, @@ -267,7 +326,7 @@ func cmdMkAgeConfig(ts *testscript.TestScript, neg bool, args []string) { } else { lines = append(lines, ` recipient = `+strconv.Quote(recipient)) } - ts.Check(os.WriteFile(configFile, []byte(chezmoitest.JoinLines(lines...)), 0o666)) + ts.Check(writeNewFile(configFile, []byte(chezmoitest.JoinLines(lines...)), 0o666)) } // cmdMkGitConfig makes a .gitconfig file in the home directory. @@ -282,8 +341,8 @@ func cmdMkGitConfig(ts *testscript.TestScript, neg bool, args []string) { if len(args) > 0 { path = ts.MkAbs(args[0]) } - ts.Check(os.MkdirAll(filepath.Dir(path), 0o777)) - ts.Check(os.WriteFile(path, []byte(chezmoitest.JoinLines( + ts.Check(os.MkdirAll(filepath.Dir(path), fs.ModePerm)) + ts.Check(writeNewFile(path, []byte(chezmoitest.JoinLines( `[core]`, ` autocrlf = false`, `[init]`, @@ -319,14 +378,14 @@ func cmdMkGPGConfig(ts *testscript.TestScript, neg bool, args []string) { ts.Check(os.Chmod(gpgHomeDir, 0o700)) } - command, err := exec.LookPath("gpg") + command, err := chezmoi.LookPath("gpg") 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(os.MkdirAll(filepath.Dir(configFile), fs.ModePerm)) lines := []string{ `encryption = "gpg"`, `[gpg]`, @@ -342,7 +401,7 @@ func cmdMkGPGConfig(ts *testscript.TestScript, neg bool, args []string) { } else { lines = append(lines, ` recipient = "`+key+`"`) } - ts.Check(os.WriteFile(configFile, []byte(chezmoitest.JoinLines(lines...)), 0o666)) + ts.Check(writeNewFile(configFile, []byte(chezmoitest.JoinLines(lines...)), 0o666)) } // cmdMkHomeDir makes and populates a home directory. @@ -360,18 +419,18 @@ func cmdMkHomeDir(ts *testscript.TestScript, neg bool, args []string) { workDir := ts.Getenv("WORK") relPath, err := filepath.Rel(workDir, path) ts.Check(err) - if err := vfst.NewBuilder().Build(vfs.NewPathFS(vfs.OSFS, workDir), map[string]interface{}{ - relPath: map[string]interface{}{ + if err := vfst.NewBuilder().Build(vfs.NewPathFS(vfs.OSFS, workDir), map[string]any{ + relPath: map[string]any{ ".create": "# contents of .create\n", - ".dir": map[string]interface{}{ + ".dir": map[string]any{ "file": "# contents of .dir/file\n", - "subdir": map[string]interface{}{ + "subdir": map[string]any{ "file": "# contents of .dir/subdir/file\n", }, }, ".empty": "", ".executable": &vfst.File{ - Perm: 0o777, + Perm: fs.ModePerm, Contents: []byte("# contents of .executable\n"), }, ".file": "# contents of .file\n", @@ -406,12 +465,12 @@ func cmdMkSourceDir(ts *testscript.TestScript, neg bool, args []string) { workDir := ts.Getenv("WORK") relPath, err := filepath.Rel(workDir, sourceDir) ts.Check(err) - err = vfst.NewBuilder().Build(vfs.NewPathFS(vfs.OSFS, workDir), map[string]interface{}{ - relPath: map[string]interface{}{ + err = vfst.NewBuilder().Build(vfs.NewPathFS(vfs.OSFS, workDir), map[string]any{ + relPath: map[string]any{ "create_dot_create": "# contents of .create\n", - "dot_dir": map[string]interface{}{ + "dot_dir": map[string]any{ "file": "# contents of .dir/file\n", - "subdir": map[string]interface{}{ + "exact_subdir": map[string]any{ "file": "# contents of .dir/subdir/file\n", }, }, @@ -537,21 +596,7 @@ func cmdUNIX2DOS(ts *testscript.TestScript, neg bool, args []string) { // goosCondition evaluates cond as a logical OR of GOARCHes or GOOSes enclosed // in parentheses, returning true if any of them match. func goosCondition(cond string) (result, valid bool) { - // FIXME remove the following two if statements when - // https://github.com/rogpeppe/go-internal/pull/147 is merged, and use - // github.com/rogpeppe/go-internal/imports.Known{Arch,OS} instead - if _, ok := KnownArch[cond]; ok { - result = runtime.GOARCH == cond - valid = true - return - } - if _, ok := KnownOS[cond]; ok { - result = runtime.GOOS == cond - valid = true - return - } - - // Interpret the condition as a logical OR of terms in parantheses. + // Interpret the condition as a logical OR of terms in parentheses. if !strings.HasPrefix(cond, "(") || !strings.HasSuffix(cond, ")") { result = false valid = false @@ -564,8 +609,11 @@ func goosCondition(cond string) (result, valid bool) { // If any of the terms are neither known GOOSes nor GOARCHes then reject the // condition as invalid. for _, term := range terms { - if _, ok := KnownOS[term]; !ok { - if _, ok := KnownArch[term]; !ok { + if term == "unix" { + continue + } + if _, ok := imports.KnownOS[term]; !ok { + if _, ok := imports.KnownArch[term]; !ok { valid = false return } @@ -578,13 +626,17 @@ func goosCondition(cond string) (result, valid bool) { // If any of the terms match either runtime.GOOS or runtime.GOARCH then // the condition is true. for _, term := range terms { - if runtime.GOOS == term || runtime.GOARCH == term { + switch { + case term == runtime.GOOS || term == "unix" && imports.UnixOS[runtime.GOOS]: + result = true + return + case term == runtime.GOARCH: result = true return } } - // Otherwise, the condtion is false. + // Otherwise, the condition is false. result = false return } @@ -607,8 +659,12 @@ func setup(env *testscript.Env) error { env.Setenv("HOME", homeDir) env.Setenv("PATH", prependDirToPath(binDir, env.Getenv("PATH"))) + if runtime.GOOS == "windows" { + env.Setenv("PATHEXT", ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.CPL") + } env.Setenv("CHEZMOICONFIGDIR", path.Join(absSlashHomeDir, ".config", "chezmoi")) env.Setenv("CHEZMOISOURCEDIR", path.Join(absSlashHomeDir, ".local", "share", "chezmoi")) + env.Setenv("CHEZMOI_GITHUB_TOKEN", os.Getenv("CHEZMOI_GITHUB_TOKEN")) switch runtime.GOOS { case "windows": @@ -621,10 +677,10 @@ func setup(env *testscript.Env) error { env.Setenv("SHELL", filepath.Join(binDir, "shell")) } - root := make(map[string]interface{}) + root := make(map[string]any) switch runtime.GOOS { case "windows": - root["/bin"] = map[string]interface{}{ + root["/bin"] = map[string]any{ // editor.cmd is a non-interactive script that appends "# edited\n" // to the end of each file and creates an empty .edited file in each // directory. @@ -634,8 +690,9 @@ func setup(env *testscript.Env) error { `:loop`, `IF EXIST %~s1\NUL (`, ` copy /y NUL "%~1\.edited" >NUL`, + // FIXME recursively edit all files if in a directory `) ELSE (`, - ` echo # edited >> "%~1"`, + ` echo.# edited>>"%~1"`, `)`, `shift`, `IF NOT "%~1"=="" goto loop`, @@ -643,10 +700,9 @@ func setup(env *testscript.Env) error { }, } default: - root["/bin"] = map[string]interface{}{ + root["/bin"] = map[string]any{ // editor is a non-interactive script that appends "# edited\n" to - // the end of each file and creates an empty .edited file in each - // directory. + // the end of each file. "editor": &vfst.File{ Perm: 0o755, Contents: []byte(chezmoitest.JoinLines( @@ -655,22 +711,15 @@ func setup(env *testscript.Env) error { `for name in $*; do`, ` if [ -d $name ]; then`, ` touch $name/.edited`, + ` for filename in $(find $name -type f); do`, + ` echo "# edited" >> $filename`, + ` done`, ` 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`, - ``, - `pwd >> '`+filepath.Join(env.WorkDir, "shell.log")+`'`, - )), - }, } } @@ -694,3 +743,14 @@ func unix2DOS(data []byte) ([]byte, error) { } return []byte(builder.String()), nil } + +func writeNewFile(filename string, data []byte, perm fs.FileMode) error { + switch _, err := os.Lstat(filename); { + case err == nil: + return fmt.Errorf("%s: %w", filename, fs.ErrExist) + case errors.Is(err, fs.ErrNotExist): + return os.WriteFile(filename, data, perm) + default: + return err + } +} diff --git a/internal/cmd/managedcmd.go b/internal/cmd/managedcmd.go index 43206922044..1f5c71c6436 100644 --- a/internal/cmd/managedcmd.go +++ b/internal/cmd/managedcmd.go @@ -2,8 +2,6 @@ package cmd import ( "fmt" - "sort" - "strings" "github.com/spf13/cobra" @@ -11,46 +9,91 @@ import ( ) type managedCmdConfig struct { - exclude *chezmoi.EntryTypeSet - include *chezmoi.EntryTypeSet + filter *chezmoi.EntryTypeFilter + pathStyle chezmoi.PathStyle + tree bool } 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), + Use: "managed [path]...", + Aliases: []string{"list"}, + Short: "List the managed entries in the destination directory", + Long: mustLongHelp("managed"), + Example: example("managed"), + Args: cobra.ArbitraryArgs, + RunE: c.makeRunEWithSourceState(c.runManagedCmd), + Annotations: newAnnotations(), } - flags := managedCmd.Flags() - flags.VarP(c.managed.exclude, "exclude", "x", "Exclude entry types") - flags.VarP(c.managed.include, "include", "i", "Include entry types") + managedCmd.Flags().VarP(c.managed.filter.Exclude, "exclude", "x", "Exclude entry types") + managedCmd.Flags().VarP(c.managed.filter.Include, "include", "i", "Include entry types") + managedCmd.Flags().VarP(&c.managed.pathStyle, "path-style", "p", "Path style") + managedCmd.Flags().BoolVarP(&c.managed.tree, "tree", "t", c.managed.tree, "Print paths as a tree") return managedCmd } func (c *Config) runManagedCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { - include := c.managed.include.Sub(c.managed.exclude) - var targetRelPaths chezmoi.RelPaths - _ = sourceState.ForEach(func(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi.SourceStateEntry) error { - targetStateEntry, err := sourceStateEntry.TargetStateEntry(c.destSystem, c.DestDirAbsPath.Join(targetRelPath)) - if err != nil { + // Build queued relPaths. When there are no arguments, start from root, + // otherwise start from arguments. + var relPaths chezmoi.RelPaths + for _, arg := range args { + if absPath, err := chezmoi.NormalizePath(arg); err != nil { return err + } else if relPath, err := absPath.TrimDirPrefix(c.DestDirAbsPath); err != nil { + return err + } else { //nolint:revive + relPaths = append(relPaths, relPath) } - if !include.IncludeTargetStateEntry(targetStateEntry) { + } + + var paths []fmt.Stringer + _ = sourceState.ForEach( + func(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi.SourceStateEntry) error { + if !c.managed.filter.IncludeSourceStateEntry(sourceStateEntry) { + return nil + } + + targetStateEntry, err := sourceStateEntry.TargetStateEntry(c.destSystem, c.DestDirAbsPath.Join(targetRelPath)) + if err != nil { + return err + } + if !c.managed.filter.IncludeTargetStateEntry(targetStateEntry) { + return nil + } + + // When arguments are given, only include paths under these arguments. + if len(relPaths) != 0 { + included := false + for _, path := range relPaths { + if targetRelPath.HasDirPrefix(path) || targetRelPath.String() == path.String() { + included = true + break + } + } + if !included { + return nil + } + } + + var path fmt.Stringer + switch c.managed.pathStyle { + case chezmoi.PathStyleAbsolute: + path = c.DestDirAbsPath.Join(targetRelPath) + case chezmoi.PathStyleRelative: + path = targetRelPath + case chezmoi.PathStyleSourceAbsolute: + path = c.SourceDirAbsPath.Join(sourceStateEntry.SourceRelPath().RelPath()) + case chezmoi.PathStyleSourceRelative: + path = sourceStateEntry.SourceRelPath().RelPath() + } + paths = append(paths, path) return nil - } - targetRelPaths = append(targetRelPaths, targetRelPath) - return nil - }) + }, + ) - sort.Sort(targetRelPaths) - builder := strings.Builder{} - for _, targetRelPath := range targetRelPaths { - fmt.Fprintln(&builder, targetRelPath) - } - return c.writeOutputString(builder.String()) + return c.writePaths(stringersToStrings(paths), writePathsOptions{ + tree: c.managed.tree, + }) } diff --git a/internal/cmd/managedcmd_test.go b/internal/cmd/managedcmd_test.go index b3cbe32aff2..d152f8f6214 100644 --- a/internal/cmd/managedcmd_test.go +++ b/internal/cmd/managedcmd_test.go @@ -4,23 +4,23 @@ import ( "bytes" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/twpayne/go-vfs/v4" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) func TestManagedCmd(t *testing.T) { + templateContents := `{{ fail "Template should not be executed" }}` for _, tc := range []struct { name string - root interface{} + root any args []string expectedOutput string }{ { name: "simple", - root: map[string]interface{}{ + root: map[string]any{ "/home/user/.local/share/chezmoi/dot_file": "# contents of .file\n", }, expectedOutput: chezmoitest.JoinLines( @@ -29,8 +29,8 @@ func TestManagedCmd(t *testing.T) { }, { name: "template", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi/dot_template.tmpl": "{{ fail \"Template should not be executed\" }}\n", + root: map[string]any{ + "/home/user/.local/share/chezmoi/dot_template.tmpl": templateContents, }, expectedOutput: chezmoitest.JoinLines( ".template", @@ -38,8 +38,8 @@ func TestManagedCmd(t *testing.T) { }, { name: "create_template", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi/create_dot_file.tmpl": "{{ fail \"Template should not be executed\" }}\n", + root: map[string]any{ + "/home/user/.local/share/chezmoi/create_dot_file.tmpl": templateContents, }, expectedOutput: chezmoitest.JoinLines( ".file", @@ -47,20 +47,34 @@ func TestManagedCmd(t *testing.T) { }, { name: "modify_template", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi/modify_dot_file.tmpl": "{{ fail \"Template should not be executed\" }}\n", + root: map[string]any{ + "/home/user/.local/share/chezmoi/modify_dot_file.tmpl": templateContents, }, expectedOutput: chezmoitest.JoinLines( ".file", ), }, + { + name: "remove", + root: map[string]any{ + "/home/user": map[string]any{ + ".local/share/chezmoi/.chezmoiremove": chezmoitest.JoinLines( + ".remove", + ), + ".remove": "", + }, + }, + expectedOutput: chezmoitest.JoinLines( + ".remove", + ), + }, { name: "script_template", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi/run_script.tmpl": "{{ fail \"Template should not be executed\" }}\n", + root: map[string]any{ + "/home/user/.local/share/chezmoi/run_script.tmpl": templateContents, }, args: []string{ - "--include", "scripts", + "--include", "always,scripts", }, expectedOutput: chezmoitest.JoinLines( "script", @@ -68,18 +82,32 @@ func TestManagedCmd(t *testing.T) { }, { name: "symlink_template", - root: map[string]interface{}{ - "/home/user/.local/share/chezmoi/symlink_dot_symlink.tmpl": "{{ fail \"Template should not be executed\" }}\n", + root: map[string]any{ + "/home/user/.local/share/chezmoi/symlink_dot_symlink.tmpl": templateContents, }, expectedOutput: chezmoitest.JoinLines( ".symlink", ), }, + { + name: "external_git_repo", + root: map[string]any{ + "/home/user/.local/share/chezmoi/.chezmoiexternal.toml": chezmoitest.JoinLines( + `[".dir"]`, + ` type = "git-repo"`, + ` url = "https://github.com/example/example.git"`, + ), + }, + expectedOutput: chezmoitest.JoinLines( + ".dir", + ), + }, } { t.Run(tc.name, func(t *testing.T) { chezmoitest.WithTestFS(t, tc.root, func(fileSystem vfs.FS) { stdout := &bytes.Buffer{} - require.NoError(t, newTestConfig(t, fileSystem, withStdout(stdout)).execute(append([]string{"managed"}, tc.args...))) + config := newTestConfig(t, fileSystem, withStdout(stdout)) + assert.NoError(t, config.execute(append([]string{"managed"}, tc.args...))) assert.Equal(t, tc.expectedOutput, stdout.String()) }) }) diff --git a/internal/cmd/mergeallcmd.go b/internal/cmd/mergeallcmd.go index c6faab34d85..bc7de70c5f4 100644 --- a/internal/cmd/mergeallcmd.go +++ b/internal/cmd/mergeallcmd.go @@ -1,6 +1,8 @@ package cmd import ( + "io/fs" + "github.com/spf13/cobra" "github.com/twpayne/chezmoi/v2/internal/chezmoi" @@ -17,33 +19,31 @@ func (c *Config) newMergeAllCmd() *cobra.Command { Short: "Perform a three-way merge for each modified file", Long: mustLongHelp("merge-all"), Example: example("merge-all"), - RunE: c.makeRunEWithSourceState(c.runMergeAllCmd), - Annotations: map[string]string{ - modifiesSourceDirectory: "true", - requiresSourceDirectory: "true", - }, + RunE: c.runMergeAllCmd, + Annotations: newAnnotations( + dryRun, + modifiesSourceDirectory, + requiresSourceDirectory, + ), } - flags := mergeAllCmd.Flags() - flags.BoolVar(&c.mergeAll.init, "init", c.mergeAll.init, "Recreate config file from template") - flags.BoolVarP(&c.mergeAll.recursive, "recursive", "r", c.mergeAll.recursive, "Recurse into subdirectories") + mergeAllCmd.Flags().BoolVar(&c.mergeAll.init, "init", c.mergeAll.init, "Recreate config file from template") + mergeAllCmd.Flags().BoolVarP(&c.mergeAll.recursive, "recursive", "r", c.mergeAll.recursive, "Recurse into subdirectories") return mergeAllCmd } -func (c *Config) runMergeAllCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { +func (c *Config) runMergeAllCmd(cmd *cobra.Command, args []string) error { var targetRelPaths []chezmoi.RelPath - dryRunSystem := chezmoi.NewDryRunSystem(c.destSystem) - preApplyFunc := func( - targetRelPath chezmoi.RelPath, targetEntryState, lastWrittenEntryState, actualEntryState *chezmoi.EntryState, - ) error { - if !targetEntryState.Equivalent(actualEntryState) { + preApplyFunc := func(targetRelPath chezmoi.RelPath, targetEntryState, lastWrittenEntryState, actualEntryState *chezmoi.EntryState) error { + if targetEntryState.Type == chezmoi.EntryStateTypeFile && !targetEntryState.Equivalent(actualEntryState) { targetRelPaths = append(targetRelPaths, targetRelPath) } - return chezmoi.Skip + return fs.SkipDir } - if err := c.applyArgs(cmd.Context(), dryRunSystem, c.DestDirAbsPath, args, applyArgsOptions{ - include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypeFiles), + if err := c.applyArgs(cmd.Context(), c.destSystem, c.DestDirAbsPath, args, applyArgsOptions{ + cmd: cmd, + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), init: c.mergeAll.init, recursive: c.mergeAll.recursive, umask: c.Umask, @@ -52,6 +52,11 @@ func (c *Config) runMergeAllCmd(cmd *cobra.Command, args []string, sourceState * return err } + sourceState, err := c.getSourceState(cmd.Context(), cmd) + if err != nil { + return err + } + for _, targetRelPath := range targetRelPaths { sourceStateEntry := sourceState.MustEntry(targetRelPath) if err := c.doMerge(targetRelPath, sourceStateEntry); err != nil { diff --git a/internal/cmd/mergecmd.go b/internal/cmd/mergecmd.go index 7fe6340a48b..17d95663477 100644 --- a/internal/cmd/mergecmd.go +++ b/internal/cmd/mergecmd.go @@ -8,36 +8,37 @@ import ( "text/template" "github.com/spf13/cobra" - "go.uber.org/multierr" "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoierrors" ) type mergeCmdConfig struct { - Command string `mapstructure:"command"` - Args []string `mapstructure:"args"` + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` } 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", - }, + 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"), + ValidArgsFunction: c.targetValidArgs, + RunE: c.makeRunEWithSourceState(c.runMergeCmd), + Annotations: newAnnotations( + modifiesSourceDirectory, + requiresSourceDirectory, + ), } return mergeCmd } func (c *Config) runMergeCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { - targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ - mustBeInSourceState: false, + targetRelPaths, err := c.targetRelPaths(sourceState, args, &targetRelPathsOptions{ + mustBeInSourceState: true, recursive: true, }) if err != nil { @@ -71,13 +72,16 @@ func (c *Config) doMerge(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi return } plaintextAbsPath = plaintextTempDirAbsPath.Join(sourceStateEntry.SourceRelPath().RelPath()) - defer func() { - err = multierr.Append(err, os.RemoveAll(plaintextAbsPath.String())) - }() + defer chezmoierrors.CombineFunc(&err, func() error { + return os.RemoveAll(plaintextTempDirAbsPath.String()) + }) var plaintext []byte if plaintext, err = sourceStateFile.Contents(); err != nil { return } + if err = chezmoi.MkdirAll(c.baseSystem, plaintextAbsPath.Dir(), 0o700); err != nil { + return + } if err = c.baseSystem.WriteFile(plaintextAbsPath, plaintext, 0o600); err != nil { return } @@ -90,9 +94,7 @@ func (c *Config) doMerge(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi // two-way merge if the source state's contents cannot be decrypted or // are an invalid template var targetStateEntry chezmoi.TargetStateEntry - if targetStateEntry, err = sourceStateEntry.TargetStateEntry( - c.destSystem, c.DestDirAbsPath.Join(targetRelPath), - ); err != nil { + if targetStateEntry, err = sourceStateEntry.TargetStateEntry(c.destSystem, c.DestDirAbsPath.Join(targetRelPath)); err != nil { err = fmt.Errorf("%s: %w", targetRelPath, err) return } @@ -140,7 +142,7 @@ func (c *Config) doMerge(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi // merge.args config option replaced all arguments to the merge command. // // Work around this by looking for any templates in merge.args. An arg - // is considered a template if, after execution as as template, it is + // is considered a template if, after execution as a template, it is // not equal to the original arg. anyTemplateArgs := false for i, arg := range c.Merge.Args { @@ -183,9 +185,7 @@ func (c *Config) doMerge(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi if encryptedContents, err = c.encryption.EncryptFile(plaintextAbsPath); err != nil { return } - if err = c.baseSystem.WriteFile( - c.SourceDirAbsPath.Join(sourceStateEntry.SourceRelPath().RelPath()), encryptedContents, 0o644, - ); err != nil { + if err = c.baseSystem.WriteFile(c.SourceDirAbsPath.Join(sourceStateEntry.SourceRelPath().RelPath()), encryptedContents, 0o644); err != nil { return } } diff --git a/internal/cmd/noupgradecmd.go b/internal/cmd/noupgradecmd.go index 2a0b8dde39a..db3402ec129 100644 --- a/internal/cmd/noupgradecmd.go +++ b/internal/cmd/noupgradecmd.go @@ -1,11 +1,10 @@ -//go:build noupgrade || windows -// +build noupgrade windows +//go:build noupgrade package cmd import ( "github.com/spf13/cobra" - "github.com/twpayne/go-vfs/v4" + "github.com/twpayne/go-vfs/v5" "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) diff --git a/internal/cmd/onepasswordsdktemplatefuncs.go b/internal/cmd/onepasswordsdktemplatefuncs.go new file mode 100644 index 00000000000..26abdbb0e8a --- /dev/null +++ b/internal/cmd/onepasswordsdktemplatefuncs.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "context" + "os" + "strings" + + "github.com/1password/onepassword-sdk-go" +) + +type onepasswordSDKConfig struct { + Token string `json:"token" mapstructure:"token" yaml:"token"` + TokenEnvVar string `json:"tokenEnvVar" mapstructure:"tokenEnvVar" yaml:"tokenEnvVar"` + itemsGetCache map[string]onepasswordSDKItem + secretsResolveCache map[string]string + client *onepassword.Client + clientErr error +} + +type onepasswordSDKItem struct { + ID string + Title string + Category onepassword.ItemCategory + VaultID string + Fields map[string]onepassword.ItemField + Sections map[string]onepassword.ItemSection +} + +func (c *Config) onepasswordSDKItemsGet(vaultID, itemID string) onepasswordSDKItem { + key := strings.Join([]string{vaultID, itemID}, "\x00") + if result, ok := c.OnepasswordSDK.itemsGetCache[key]; ok { + return result + } + + ctx := context.TODO() + + client, err := c.onepasswordSDKClient(ctx) + if err != nil { + panic(err) + } + + item, err := client.Items.Get(ctx, vaultID, itemID) + if err != nil { + panic(err) + } + + if c.OnepasswordSDK.itemsGetCache == nil { + c.OnepasswordSDK.itemsGetCache = make(map[string]onepasswordSDKItem) + } + + fields := make(map[string]onepassword.ItemField) + for _, field := range item.Fields { + fields[field.ID] = field + } + + sections := make(map[string]onepassword.ItemSection) + for _, section := range item.Sections { + sections[section.ID] = section + } + + onepasswordSDKItem := onepasswordSDKItem{ + ID: item.ID, + Title: item.Title, + Category: item.Category, + VaultID: item.VaultID, + Fields: fields, + Sections: sections, + } + + c.OnepasswordSDK.itemsGetCache[key] = onepasswordSDKItem + + return onepasswordSDKItem +} + +func (c *Config) onepasswordSDKSecretsResolve(secretReference string) string { + if result, ok := c.OnepasswordSDK.secretsResolveCache[secretReference]; ok { + return result + } + + ctx := context.TODO() + + client, err := c.onepasswordSDKClient(ctx) + if err != nil { + panic(err) + } + + secret, err := client.Secrets.Resolve(ctx, secretReference) + if err != nil { + panic(err) + } + + if c.OnepasswordSDK.secretsResolveCache == nil { + c.OnepasswordSDK.secretsResolveCache = make(map[string]string) + } + c.OnepasswordSDK.secretsResolveCache[secretReference] = secret + + return secret +} + +func (c *Config) onepasswordSDKClient(ctx context.Context) (*onepassword.Client, error) { + if c.OnepasswordSDK.client != nil || c.OnepasswordSDK.clientErr != nil { + return c.OnepasswordSDK.client, c.OnepasswordSDK.clientErr + } + + token := c.OnepasswordSDK.Token + if token == "" { + token = os.Getenv(c.OnepasswordSDK.TokenEnvVar) + } + + version := c.versionInfo.Version + if version == "" { + version = c.versionInfo.Commit + } + if version == "" { + version = onepassword.DefaultIntegrationVersion + } + + c.OnepasswordSDK.client, c.OnepasswordSDK.clientErr = onepassword.NewClient( + ctx, + onepassword.WithIntegrationInfo("chezmoi", version), + onepassword.WithServiceAccountToken(token), + ) + + return c.OnepasswordSDK.client, c.OnepasswordSDK.clientErr +} diff --git a/internal/cmd/onepasswordtemplatefuncs.go b/internal/cmd/onepasswordtemplatefuncs.go index f7f6401e9bb..a7054ba644b 100644 --- a/internal/cmd/onepasswordtemplatefuncs.go +++ b/internal/cmd/onepasswordtemplatefuncs.go @@ -1,114 +1,480 @@ package cmd import ( - "bytes" "encoding/json" + "errors" "fmt" + "os" "os/exec" + "regexp" "strings" + + "github.com/coreos/go-semver/semver" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +type onepasswordMode string + +const ( + onepasswordModeAccount onepasswordMode = "account" + onepasswordModeConnect onepasswordMode = "connect" + onepasswordModeService onepasswordMode = "service" ) +type withSessionTokenType bool + +const ( + withSessionToken withSessionTokenType = true + withoutSessionToken withSessionTokenType = false +) + +var ( + onepasswordVersionRx = regexp.MustCompile(`^(\d+\.\d+\.\d+\S*)`) + onepasswordMinVersion = semver.Version{Major: 2} +) + +type onepasswordAccount struct { + URL string `json:"url"` + Email string `json:"email"` + UserUUID string `json:"user_uuid"` //nolint:tagliatelle + AccountUUID string `json:"account_uuid"` //nolint:tagliatelle + Shorthand string `json:"shorthand"` +} + type onepasswordConfig struct { - Command string - outputCache map[string][]byte + Command string `json:"command" mapstructure:"command" yaml:"command"` + Prompt bool `json:"prompt" mapstructure:"prompt" yaml:"prompt"` + Mode onepasswordMode `json:"mode" mapstructure:"mode" yaml:"mode"` + outputCache map[string][]byte + sessionTokens map[string]string + accountMap map[string]string + accountMapErr error + modeChecked bool } -type onePasswordItem struct { - Details struct { - Fields []map[string]interface{} `json:"fields"` - Sections []struct { - Fields []map[string]interface{} `json:"fields,omitempty"` - } `json:"sections"` - } `json:"details"` +type onepasswordArgs struct { + item string + vault string + account string + args []string } -func (c *Config) onepasswordItem(args ...string) *onePasswordItem { - onepasswordArgs := getOnepasswordArgs([]string{"get", "item"}, args) - output := c.onepasswordOutput(onepasswordArgs) - var onepasswordItem onePasswordItem - if err := json.Unmarshal(output, &onepasswordItem); err != nil { - returnTemplateError(fmt.Errorf("%s: %w\n%s", shellQuoteCommand(c.Onepassword.Command, onepasswordArgs), err, output)) - return nil +type onepasswordItem struct { + Fields []map[string]any `json:"fields"` +} + +func (c *Config) onepasswordTemplateFunc(userArgs ...string) map[string]any { + if err := c.onepasswordCheckMode(); err != nil { + panic(err) + } + + args, err := c.newOnepasswordArgs([]string{"item", "get", "--format", "json"}, userArgs) + if err != nil { + panic(err) + } + + output, err := c.onepasswordOutput(args, withSessionToken) + if err != nil { + panic(err) } - return &onepasswordItem + + var data map[string]any + if err := json.Unmarshal(output, &data); err != nil { + panic(newParseCmdOutputError(c.Onepassword.Command, args.args, output, err)) + } + return data } -func (c *Config) onepasswordDetailsFieldsTemplateFunc(args ...string) map[string]interface{} { - onepasswordItem := c.onepasswordItem(args...) - result := make(map[string]interface{}) - for _, field := range onepasswordItem.Details.Fields { - if designation, ok := field["designation"].(string); ok { - result[designation] = field +func (c *Config) onepasswordDetailsFieldsTemplateFunc(userArgs ...string) map[string]any { + item, err := c.onepasswordItem(userArgs) + if err != nil { + panic(err) + } + + result := make(map[string]any) + for _, field := range item.Fields { + if _, ok := field["section"]; ok { + continue + } + if id, ok := field["id"].(string); ok && id != "" { + result[id] = field + continue + } + if label, ok := field["label"].(string); ok && label != "" { + result[label] = field + continue } } return result } -func (c *Config) onepasswordItemFieldsTemplateFunc(args ...string) map[string]interface{} { - onepasswordItem := c.onepasswordItem(args...) - result := make(map[string]interface{}) - for _, section := range onepasswordItem.Details.Sections { - for _, field := range section.Fields { - if t, ok := field["t"].(string); ok { - result[t] = field - } +func (c *Config) onepasswordDocumentTemplateFunc(userArgs ...string) string { + if err := c.onepasswordCheckMode(); err != nil { + panic(err) + } + + if c.Onepassword.Mode == onepasswordModeConnect { + panic(fmt.Errorf("onepasswordDocument cannot be used in %s mode", onepasswordModeConnect)) + } + + args, err := c.newOnepasswordArgs([]string{"document", "get"}, userArgs) + if err != nil { + panic(err) + } + + output, err := c.onepasswordOutput(args, withSessionToken) + if err != nil { + panic(err) + } + return string(output) +} + +func (c *Config) onepasswordItemFieldsTemplateFunc(userArgs ...string) map[string]any { + item, err := c.onepasswordItem(userArgs) + if err != nil { + panic(err) + } + + result := make(map[string]any) + for _, field := range item.Fields { + if _, ok := field["section"]; !ok { + continue + } + if label, ok := field["label"].(string); ok { + result[label] = field } } return result } -func (c *Config) onepasswordDocumentTemplateFunc(args ...string) string { - onepasswordArgs := getOnepasswordArgs([]string{"get", "document"}, args) - output := c.onepasswordOutput(onepasswordArgs) - return string(output) +// onepasswordGetOrRefreshSessionToken will return the current session token if +// the token within the environment is still valid. Otherwise it will ask the +// user to sign in and get the new token. +func (c *Config) onepasswordGetOrRefreshSessionToken(args *onepasswordArgs) (string, error) { + if !c.Onepassword.Prompt { + return "", nil + } + + // Check if there's already a valid session token cached in this run for + // this account. + sessionToken, ok := c.Onepassword.sessionTokens[args.account] + if ok { + return sessionToken, nil + } + + // If no account has been given then look for any session tokens in the + // environment. + if args.account == "" { + sessionToken = onepasswordUniqueSessionToken(os.Environ()) + if sessionToken != "" { + return sessionToken, nil + } + } + + commandArgs := []string{"signin"} + if args.account != "" { + commandArgs = append(commandArgs, "--account", args.account) + } + commandArgs = append(commandArgs, "--raw") + if session := os.Getenv("OP_SESSION_" + args.account); session != "" { + commandArgs = append(commandArgs, "--session", session) + } + + cmd := exec.Command(c.Onepassword.Command, commandArgs...) //nolint:gosec + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) + if err != nil { + return "", newCmdOutputError(cmd, output, err) + } + sessionToken = strings.TrimSpace(string(output)) + + // Cache the session token in memory, so we don't try to refresh it again + // for this run for this account. + if c.Onepassword.sessionTokens == nil { + c.Onepassword.sessionTokens = make(map[string]string) + } + c.Onepassword.sessionTokens[args.account] = sessionToken + + return sessionToken, nil } -func (c *Config) onepasswordOutput(args []string) []byte { - key := strings.Join(args, "\x00") +func (c *Config) onepasswordItem(userArgs []string) (*onepasswordItem, error) { + args, err := c.newOnepasswordArgs([]string{"item", "get", "--format", "json"}, userArgs) + if err != nil { + return nil, err + } + + output, err := c.onepasswordOutput(args, withSessionToken) + if err != nil { + return nil, err + } + + var item onepasswordItem + if err := json.Unmarshal(output, &item); err != nil { + return nil, newParseCmdOutputError(c.Onepassword.Command, args.args, output, err) + } + return &item, nil +} + +func (c *Config) onepasswordOutput(args *onepasswordArgs, withSessionToken withSessionTokenType) ([]byte, error) { + key := strings.Join(args.args, "\x00") if output, ok := c.Onepassword.outputCache[key]; ok { - return output + return output, nil + } + + commandArgs := args.args + if c.Onepassword.Mode == onepasswordModeAccount && withSessionToken { + sessionToken, err := c.onepasswordGetOrRefreshSessionToken(args) + if err != nil { + return nil, err + } + if sessionToken != "" { + commandArgs = append([]string{"--session", sessionToken}, commandArgs...) + } } - name := c.Onepassword.Command - cmd := exec.Command(name, args...) - cmd.Stdin = c.stdin - stderr := &bytes.Buffer{} - cmd.Stderr = stderr - output, err := c.baseSystem.IdempotentCmdOutput(cmd) + cmd := exec.Command(c.Onepassword.Command, commandArgs...) //nolint:gosec + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) if err != nil { - returnTemplateError(fmt.Errorf("%s: %w: %s", shellQuoteCommand(name, args), err, bytes.TrimSpace(stderr.Bytes()))) - return nil + return nil, newCmdOutputError(cmd, output, err) } if c.Onepassword.outputCache == nil { c.Onepassword.outputCache = make(map[string][]byte) } c.Onepassword.outputCache[key] = output - return output + + return output, nil } -func (c *Config) onepasswordTemplateFunc(args ...string) map[string]interface{} { - onepasswordArgs := getOnepasswordArgs([]string{"get", "item"}, args) - output := c.onepasswordOutput(onepasswordArgs) - var data map[string]interface{} - if err := json.Unmarshal(output, &data); err != nil { - returnTemplateError(fmt.Errorf("%s: %w\n%s", shellQuoteCommand(c.Onepassword.Command, onepasswordArgs), err, output)) - return nil +func (c *Config) onepasswordReadTemplateFunc(url string, args ...string) string { + if err := c.onepasswordCheckMode(); err != nil { + panic(err) } - return data + + onepasswordArgs := &onepasswordArgs{ + args: []string{"read", "--no-newline", url}, + } + + switch len(args) { + case 0: + // Do nothing. + case 1: + if err := onepasswordCheckInvalidAccountParameters(c.Onepassword.Mode); err != nil { + panic(err) + } + + onepasswordArgs.account = c.onepasswordAccount(args[0]) + onepasswordArgs.args = append(onepasswordArgs.args, "--account", onepasswordArgs.account) + default: + panic(fmt.Errorf("expected 1..2 arguments, got %d", len(args)+1)) + } + + output, err := c.onepasswordOutput(onepasswordArgs, withSessionToken) + if err != nil { + panic(err) + } + return string(output) +} + +func (c *Config) onepasswordAccount(key string) string { + // This should not happen, but better to be safe + if err := onepasswordCheckInvalidAccountParameters(c.Onepassword.Mode); err != nil { + panic(err) + } + + accounts, err := c.onepasswordAccounts() + if err != nil { + panic(err) + } + + if account, exists := accounts[key]; exists { + return account + } + + panic(fmt.Errorf("no 1Password account found matching %s", key)) +} + +// onepasswordAccounts returns a map of keys to unique account UUIDs. +func (c *Config) onepasswordAccounts() (map[string]string, error) { + // This should not happen, but better to be safe + if err := onepasswordCheckInvalidAccountParameters(c.Onepassword.Mode); err != nil { + return nil, err + } + + if c.Onepassword.accountMap != nil || c.Onepassword.accountMapErr != nil { + return c.Onepassword.accountMap, c.Onepassword.accountMapErr + } + + args := &onepasswordArgs{ + args: []string{"account", "list", "--format=json"}, + } + + output, err := c.onepasswordOutput(args, withoutSessionToken) + if err != nil { + c.Onepassword.accountMapErr = err + return nil, c.Onepassword.accountMapErr + } + + var accounts []onepasswordAccount + if err := json.Unmarshal(output, &accounts); err != nil { + c.Onepassword.accountMapErr = err + return nil, c.Onepassword.accountMapErr + } + + c.Onepassword.accountMap = onepasswordAccountMap(accounts) + return c.Onepassword.accountMap, c.Onepassword.accountMapErr } -func getOnepasswordArgs(baseArgs, args []string) []string { - if len(args) < 1 || len(args) > 3 { - returnTemplateError(fmt.Errorf("expected 1, 2, or 3 arguments, got %d", len(args))) +func (c *Config) newOnepasswordArgs(baseArgs, userArgs []string) (*onepasswordArgs, error) { + maxArgs := 3 + if c.Onepassword.Mode != onepasswordModeAccount { + maxArgs = 2 + } + + // `session` and `connect` modes do not support the account parameter. Better + // to error out early. + if len(userArgs) < 1 || maxArgs < len(userArgs) { + if err := onepasswordCheckInvalidAccountParameters(c.Onepassword.Mode); maxArgs < len(userArgs) && err != nil { + return nil, err + } + + return nil, fmt.Errorf("expected 1..%d arguments in %s mode, got %d", maxArgs, c.Onepassword.Mode, len(userArgs)) + } + + a := &onepasswordArgs{ + args: baseArgs, + } + + a.item = userArgs[0] + a.args = append(a.args, a.item) + + if len(userArgs) > 1 && userArgs[1] != "" { + a.vault = userArgs[1] + a.args = append(a.args, "--vault", a.vault) + } + + if len(userArgs) > 2 && userArgs[2] != "" { + a.account = c.onepasswordAccount(userArgs[2]) + a.args = append(a.args, "--account", a.account) + } + return a, nil +} + +// onepasswordAccountMap returns a map of unique IDs to account UUIDs. +func onepasswordAccountMap(accounts []onepasswordAccount) map[string]string { + // Build a map of keys to account UUIDs. + accountsMap := make(map[string][]string) + for _, account := range accounts { + keys := []string{ + account.URL, + account.Email, + account.UserUUID, + account.AccountUUID, + account.Shorthand, + } + + accountName, _, accountNameOk := strings.Cut(account.URL, ".") + if accountNameOk { + keys = append(keys, accountName) + } + + emailName, _, emailNameOk := strings.Cut(account.Email, "@") + if emailNameOk { + keys = append(keys, emailName, emailName+"@"+account.URL) + } + + if accountNameOk && emailNameOk { + keys = append(keys, emailName+"@"+accountName) + } + + for _, key := range keys { + accountsMap[key] = append(accountsMap[key], account.AccountUUID) + } + } + + // Select unique, non-empty keys. + accountMap := make(map[string]string) + for key, values := range accountsMap { + if key != "" && len(values) == 1 { + accountMap[key] = values[0] + } + } + + return accountMap +} + +// onepasswordUniqueSessionToken will look for any session tokens in the +// environment. If it finds exactly one then it will return it. +func onepasswordUniqueSessionToken(environ []string) string { + var token string + for _, env := range environ { + key, value, found := strings.Cut(env, "=") + if found && strings.HasPrefix(key, "OP_SESSION_") { + if token != "" { + return "" + } + token = value + } + } + return token +} + +// onepasswordCheckMode verifies that things are set up correctly for the +// 1Password mode. +func (c *Config) onepasswordCheckMode() error { + if c.Onepassword.modeChecked { return nil } - baseArgs = append(baseArgs, args[0]) - if len(args) > 1 { - baseArgs = append(baseArgs, "--vault", args[1]) + + c.Onepassword.modeChecked = true + + switch c.Onepassword.Mode { + case onepasswordModeAccount: + if os.Getenv("OP_SERVICE_ACCOUNT_TOKEN") != "" { + return errors.New("onepassword.mode is account, but OP_SERVICE_ACCOUNT_TOKEN is set") + } + + if os.Getenv("OP_CONNECT_HOST") != "" && os.Getenv("OP_CONNECT_TOKEN") != "" { + return errors.New("onepassword.mode is account, but OP_CONNECT_HOST and OP_CONNECT_TOKEN are set") + } + + case onepasswordModeConnect: + if os.Getenv("OP_CONNECT_HOST") == "" { + return errors.New("onepassword.mode is connect, but OP_CONNECT_HOST is not set") + } + + if os.Getenv("OP_CONNECT_TOKEN") == "" { + return errors.New("onepassword.mode is connect, but OP_CONNECT_TOKEN is not set") + } + + if os.Getenv("OP_SERVICE_ACCOUNT_TOKEN") != "" { + return errors.New("onepassword.mode is connect, but OP_SERVICE_ACCOUNT_TOKEN is set") + } + + case onepasswordModeService: + if os.Getenv("OP_SERVICE_ACCOUNT_TOKEN") == "" { + return errors.New("onepassword.mode is service, but OP_SERVICE_ACCOUNT_TOKEN is not set") + } + + if os.Getenv("OP_CONNECT_HOST") != "" && os.Getenv("OP_CONNECT_TOKEN") != "" { + return errors.New("onepassword.mode is service, but OP_CONNECT_HOST and OP_CONNECT_TOKEN are set") + } } - if len(args) > 2 { - baseArgs = append(baseArgs, "--account", args[2]) + + return nil +} + +// onepasswordCheckInvalidAccountParameters returns the error "1Password +// account parameters cannot be used in %s mode" if the provided mode is not +// account mode. +func onepasswordCheckInvalidAccountParameters(mode onepasswordMode) error { + if mode != onepasswordModeAccount { + return fmt.Errorf("1Password account parameters cannot be used in %s mode", mode) } - return baseArgs + return nil } diff --git a/internal/cmd/onepasswordtemplatefuncs_test.go b/internal/cmd/onepasswordtemplatefuncs_test.go new file mode 100644 index 00000000000..3071c666508 --- /dev/null +++ b/internal/cmd/onepasswordtemplatefuncs_test.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestOnepasswordAccountMap(t *testing.T) { + for _, tc := range []struct { + name string + accounts []onepasswordAccount + expected map[string]string + }{ + { + name: "single_account_without_shorthand", + accounts: []onepasswordAccount{ + { + URL: "account1.1password.ca", + Email: "my@email.com", + UserUUID: "some-user-uuid", + AccountUUID: "some-account-uuid", + }, + }, + expected: map[string]string{ + "account1.1password.ca": "some-account-uuid", + "account1": "some-account-uuid", + "my@account1.1password.ca": "some-account-uuid", + "my@account1": "some-account-uuid", + "my@email.com": "some-account-uuid", + "my": "some-account-uuid", + "some-account-uuid": "some-account-uuid", + "some-user-uuid": "some-account-uuid", + }, + }, + { + name: "single_account_with_shorthand", + accounts: []onepasswordAccount{ + { + URL: "account1.1password.ca", + Email: "my@email.com", + UserUUID: "some-user-uuid", + AccountUUID: "some-account-uuid", + Shorthand: "some-account-shorthand", + }, + }, + expected: map[string]string{ + "account1.1password.ca": "some-account-uuid", + "account1": "some-account-uuid", + "my@account1.1password.ca": "some-account-uuid", + "my@account1": "some-account-uuid", + "my@email.com": "some-account-uuid", + "my": "some-account-uuid", + "some-account-shorthand": "some-account-uuid", + "some-account-uuid": "some-account-uuid", + "some-user-uuid": "some-account-uuid", + }, + }, + { + name: "multiple_unambiguous_accounts", + accounts: []onepasswordAccount{ + { + URL: "account1.1password.ca", + Email: "my@email.com", + UserUUID: "some-user-uuid", + AccountUUID: "some-account-uuid", + Shorthand: "some-account-shorthand", + }, + { + URL: "account2.1password.ca", + Email: "me@otheremail.org", + UserUUID: "some-other-user-uuid", + AccountUUID: "some-other-account-uuid", + Shorthand: "some-other-account-shorthand", + }, + }, + expected: map[string]string{ + "account1.1password.ca": "some-account-uuid", + "account1": "some-account-uuid", + "account2.1password.ca": "some-other-account-uuid", + "account2": "some-other-account-uuid", + "me@account2.1password.ca": "some-other-account-uuid", + "me@account2": "some-other-account-uuid", + "me@otheremail.org": "some-other-account-uuid", + "me": "some-other-account-uuid", + "my@account1.1password.ca": "some-account-uuid", + "my@account1": "some-account-uuid", + "my@email.com": "some-account-uuid", + "my": "some-account-uuid", + "some-account-shorthand": "some-account-uuid", + "some-account-uuid": "some-account-uuid", + "some-other-account-shorthand": "some-other-account-uuid", + "some-other-account-uuid": "some-other-account-uuid", + "some-other-user-uuid": "some-other-account-uuid", + "some-user-uuid": "some-account-uuid", + }, + }, + { + name: "multiple_ambiguous_accounts", + accounts: []onepasswordAccount{ + { + URL: "account1.1password.ca", + Email: "my@email.com", + UserUUID: "some-user-uuid", + AccountUUID: "some-account-uuid", + Shorthand: "some-account-shorthand", + }, + { + URL: "account1.1password.ca", + Email: "your@email.com", + UserUUID: "some-other-user-uuid", + AccountUUID: "some-other-account-uuid", + Shorthand: "some-other-account-shorthand", + }, + }, + expected: map[string]string{ + "my@account1.1password.ca": "some-account-uuid", + "my@account1": "some-account-uuid", + "my@email.com": "some-account-uuid", + "my": "some-account-uuid", + "some-account-shorthand": "some-account-uuid", + "some-account-uuid": "some-account-uuid", + "some-other-account-shorthand": "some-other-account-uuid", + "some-other-account-uuid": "some-other-account-uuid", + "some-other-user-uuid": "some-other-account-uuid", + "some-user-uuid": "some-account-uuid", + "your@account1.1password.ca": "some-other-account-uuid", + "your@account1": "some-other-account-uuid", + "your@email.com": "some-other-account-uuid", + "your": "some-other-account-uuid", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actual := onepasswordAccountMap(tc.accounts) + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/internal/cmd/passholetemplatefuncs.go b/internal/cmd/passholetemplatefuncs.go new file mode 100644 index 00000000000..29d4abd7bb4 --- /dev/null +++ b/internal/cmd/passholetemplatefuncs.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "os/exec" + "slices" + + "github.com/coreos/go-semver/semver" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +type passholeCacheKey struct { + path string + field string +} + +type passholeConfig struct { + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + Prompt bool `json:"prompt" mapstructure:"prompt" yaml:"prompt"` + cache map[passholeCacheKey]string + password string +} + +var passholeMinVersion = semver.Version{Major: 1, Minor: 10, Patch: 0} + +func (c *Config) passholeTemplateFunc(path, field string) string { + key := passholeCacheKey{ + path: path, + field: field, + } + if value, ok := c.Passhole.cache[key]; ok { + return value + } + + args := slices.Clone(c.Passhole.Args) + var stdin io.Reader + if c.Passhole.Prompt { + if c.Passhole.password == "" { + password, err := c.readPassword("Enter database password: ") + if err != nil { + panic(err) + } + c.Passhole.password = password + } + args = append(args, "--password", "-") + stdin = bytes.NewBufferString(c.Passhole.password + "\n") + } + args = append(args, "show", "--field", field, path) + output, err := c.passholeOutput(c.Passhole.Command, args, stdin) + if err != nil { + panic(err) + } + + if c.Passhole.cache == nil { + c.Passhole.cache = make(map[passholeCacheKey]string) + } + c.Passhole.cache[key] = output + return output +} + +func (c *Config) passholeOutput(name string, args []string, stdin io.Reader) (string, error) { + cmd := exec.Command(name, args...) + cmd.Stdin = stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) + if err != nil { + return "", newCmdOutputError(cmd, output, err) + } + return string(output), nil +} diff --git a/internal/cmd/passtemplatefuncs.go b/internal/cmd/passtemplatefuncs.go index 880e97f3827..e34bec8ded1 100644 --- a/internal/cmd/passtemplatefuncs.go +++ b/internal/cmd/passtemplatefuncs.go @@ -2,47 +2,67 @@ package cmd import ( "bytes" - "fmt" + "os" "os/exec" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) type passConfig struct { - Command string + Command string `json:"command" mapstructure:"command" yaml:"command"` cache map[string][]byte } -func (c *Config) passOutput(id string) []byte { - if output, ok := c.Pass.cache[id]; ok { - return output +func (c *Config) passTemplateFunc(id string) string { + output, err := c.passOutput(id) + if err != nil { + panic(err) } + firstLine, _, _ := bytes.Cut(output, []byte{'\n'}) + return string(bytes.TrimSpace(firstLine)) +} - 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) +func (c *Config) passFieldsTemplateFunc(id string) map[string]string { + output, err := c.passOutput(id) if err != nil { - returnTemplateError(fmt.Errorf("%s: %w", shellQuoteCommand(name, args), err)) - return nil + panic(err) } - if c.Pass.cache == nil { - c.Pass.cache = make(map[string][]byte) + result := make(map[string]string) + for _, line := range bytes.Split(output, []byte{'\n'}) { + if key, value, ok := bytes.Cut(line, []byte{':'}); ok { + result[string(bytes.TrimSpace(key))] = string(bytes.TrimSpace(value)) + } } - c.Pass.cache[id] = output - - return output + return result } -func (c *Config) passTemplateFunc(id string) string { - output := c.passOutput(id) - if index := bytes.IndexByte(output, '\n'); index != -1 { - return string(output[:index]) +func (c *Config) passRawTemplateFunc(id string) string { + output, err := c.passOutput(id) + if err != nil { + panic(err) } return string(output) } -func (c *Config) passRawTemplateFunc(id string) string { - return string(c.passOutput(id)) +func (c *Config) passOutput(id string) ([]byte, error) { + if output, ok := c.Pass.cache[id]; ok { + return output, nil + } + + args := []string{"show", id} + cmd := exec.Command(c.Pass.Command, args...) //nolint:gosec + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) + if err != nil { + return nil, newCmdOutputError(cmd, output, err) + } + + if c.Pass.cache == nil { + c.Pass.cache = make(map[string][]byte) + } + c.Pass.cache[id] = output + + return output, nil } diff --git a/internal/cmd/pathlist.go b/internal/cmd/pathlist.go new file mode 100644 index 00000000000..4b77f187c21 --- /dev/null +++ b/internal/cmd/pathlist.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "strings" + + "github.com/twpayne/chezmoi/v2/internal/chezmoimaps" +) + +type pathListTreeNode struct { + component string + children map[string]*pathListTreeNode +} + +func newPathListTreeNode(component string) *pathListTreeNode { + return &pathListTreeNode{ + component: component, + children: make(map[string]*pathListTreeNode), + } +} + +func newPathListTreeFromPathsSlice(paths []string) *pathListTreeNode { + root := newPathListTreeNode("") + for _, path := range paths { + n := root + for _, component := range strings.Split(path, "/") { + child, ok := n.children[component] + if !ok { + child = newPathListTreeNode(component) + n.children[component] = child + } + n = child + } + } + return root +} + +func (n *pathListTreeNode) write(sb *strings.Builder, prefix, indent string) { + sb.WriteString(prefix) + sb.WriteString(n.component) + sb.WriteByte('\n') + n.writeChildren(sb, prefix+indent, indent) +} + +func (n *pathListTreeNode) writeChildren(sb *strings.Builder, prefix, indent string) { + for _, key := range chezmoimaps.SortedKeys(n.children) { + child := n.children[key] + child.write(sb, prefix, indent) + } +} diff --git a/internal/cmd/pathlist_test.go b/internal/cmd/pathlist_test.go new file mode 100644 index 00000000000..3a06428bfdf --- /dev/null +++ b/internal/cmd/pathlist_test.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/twpayne/chezmoi/v2/internal/chezmoitest" +) + +func TestNewNodeFromPathsSlice(t *testing.T) { + for _, tc := range []struct { + name string + paths []string + expected []string + }{ + { + name: "empty", + }, + { + name: "root", + paths: []string{ + "a", + }, + expected: []string{ + "a", + }, + }, + { + name: "simple", + paths: []string{ + "a", + "b", + }, + expected: []string{ + "a", + "b", + }, + }, + { + name: "simple_nesting", + paths: []string{ + "a/b", + }, + expected: []string{ + "a", + " b", + }, + }, + { + name: "multiple_simple_nesting", + paths: []string{ + "a/a", + "a/b", + "b/a", + "b/b", + }, + expected: []string{ + "a", + " a", + " b", + "b", + " a", + " b", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var sb strings.Builder + newPathListTreeFromPathsSlice(tc.paths).writeChildren(&sb, "", " ") + assert.Equal(t, chezmoitest.JoinLines(tc.expected...), sb.String()) + }) + } +} diff --git a/internal/cmd/pinentry.go b/internal/cmd/pinentry.go index 3150c89718c..7bed35ba493 100644 --- a/internal/cmd/pinentry.go +++ b/internal/cmd/pinentry.go @@ -1,23 +1,24 @@ package cmd import ( - "github.com/twpayne/go-pinentry" - "go.uber.org/multierr" + "github.com/twpayne/go-pinentry/v4" + + "github.com/twpayne/chezmoi/v2/internal/chezmoierrors" ) type pinEntryConfig struct { - Command string `mapstructure:"command"` - Args []string `mapstructure:"args"` - Options []string `mapstructure:"options"` + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + Options []string `json:"options" mapstructure:"options" yaml:"options"` } var pinEntryDefaultOptions = []string{ pinentry.OptionAllowExternalPasswordCache, } -func (c *Config) readPINEntry(prompt string) (pin string, err error) { +func (c *Config) readPINEntry(prompt string) (string, error) { var client *pinentry.Client - client, err = pinentry.NewClient( + client, err := pinentry.NewClient( pinentry.WithArgs(c.PINEntry.Args), pinentry.WithBinaryName(c.PINEntry.Command), pinentry.WithGPGTTY(), @@ -26,12 +27,14 @@ func (c *Config) readPINEntry(prompt string) (pin string, err error) { pinentry.WithTitle("chezmoi"), ) if err != nil { - return + return "", err + } + defer chezmoierrors.CombineFunc(&err, client.Close) + + result, err := client.GetPIN() + if err != nil { + return "", err } - defer func() { - err = multierr.Append(err, client.Close()) - }() - pin, _, err = client.GetPIN() - return + return result.PIN, nil } diff --git a/internal/cmd/prompt.go b/internal/cmd/prompt.go new file mode 100644 index 00000000000..cb767408686 --- /dev/null +++ b/internal/cmd/prompt.go @@ -0,0 +1,259 @@ +package cmd + +import ( + "bufio" + "fmt" + "slices" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoibubbles" +) + +// readBool reads a bool. +func (c *Config) readBool(prompt string, defaultValue *bool) (bool, error) { + switch { + case c.noTTY: + fullPrompt := prompt + if defaultValue != nil { + fullPrompt += " (default " + strconv.FormatBool(*defaultValue) + ")" + } + fullPrompt += "? " + for { + valueStr, err := c.readLineRaw(fullPrompt) + if err != nil { + return false, err + } + if valueStr == "" && defaultValue != nil { + return *defaultValue, nil + } + if value, err := chezmoi.ParseBool(valueStr); err == nil { + return value, nil + } + } + default: + initModel := chezmoibubbles.NewBoolInputModel(prompt, defaultValue) + finalModel, err := runCancelableModel(initModel) + if err != nil { + return false, err + } + return finalModel.Value(), nil + } +} + +// readChoice reads a choice. +func (c *Config) readChoice(prompt string, choices []string, defaultValue *string) (string, error) { + switch { + case c.noTTY: + fullPrompt := prompt + " (" + strings.Join(choices, "/") + if defaultValue != nil { + fullPrompt += ", default " + *defaultValue + } + fullPrompt += ")? " + abbreviations := chezmoi.UniqueAbbreviations(choices) + for { + value, err := c.readLineRaw(fullPrompt) + if err != nil { + return "", err + } + if value == "" && defaultValue != nil { + return *defaultValue, nil + } + if value, ok := abbreviations[value]; ok { + return value, nil + } + } + default: + initModel := chezmoibubbles.NewChoiceInputModel(prompt, choices, defaultValue) + finalModel, err := runCancelableModel(initModel) + if err != nil { + return "", err + } + return finalModel.Value(), nil + } +} + +// readInt reads an int. +func (c *Config) readInt(prompt string, defaultValue *int64) (int64, error) { + switch { + case c.noTTY: + fullPrompt := prompt + if defaultValue != nil { + fullPrompt += " (default " + strconv.FormatInt(*defaultValue, 10) + ")" + } + fullPrompt += "? " + for { + valueStr, err := c.readLineRaw(fullPrompt) + if err != nil { + return 0, err + } + if valueStr == "" && defaultValue != nil { + return *defaultValue, nil + } + if value, err := strconv.ParseInt(valueStr, 10, 64); err == nil { + return value, nil + } + } + default: + initModel := chezmoibubbles.NewIntInputModel(prompt, defaultValue) + finalModel, err := runCancelableModel(initModel) + if err != nil { + return 0, err + } + return finalModel.Value(), nil + } +} + +// readLineRaw reads a line, trimming leading and trailing whitespace. +func (c *Config) readLineRaw(prompt string) (string, error) { + _, err := c.stdout.Write([]byte(prompt)) + if err != nil { + return "", err + } + if c.bufioReader == nil { + c.bufioReader = bufio.NewReader(c.stdin) + } + line, err := c.bufioReader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(line), nil +} + +// readPassword reads a password. +func (c *Config) readPassword(prompt string) (string, error) { + switch { + case c.noTTY: + return c.readLineRaw(prompt) + case c.PINEntry.Command != "": + return c.readPINEntry(prompt) + default: + initModel := chezmoibubbles.NewPasswordInputModel(prompt) + finalModel, err := runCancelableModel(initModel) + if err != nil { + return "", err + } + return finalModel.Value(), nil + } +} + +// readString reads a string. +func (c *Config) readString(prompt string, defaultValue *string) (string, error) { + switch { + case c.noTTY: + fullPrompt := prompt + if defaultValue != nil { + fullPrompt += " (default " + strconv.Quote(*defaultValue) + ")" + } + fullPrompt += "? " + value, err := c.readLineRaw(fullPrompt) + if err != nil { + return "", err + } + if value == "" && defaultValue != nil { + return *defaultValue, nil + } + return value, nil + default: + initModel := chezmoibubbles.NewStringInputModel(prompt, defaultValue) + finalModel, err := runCancelableModel(initModel) + if err != nil { + return "", err + } + return strings.TrimSpace(finalModel.Value()), nil + } +} + +func (c *Config) promptBool(prompt string, args ...bool) (bool, error) { + var defaultValue *bool + switch len(args) { + case 0: + // Do nothing. + case 1: + defaultValue = &args[0] + default: + return false, fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + } + if c.interactiveTemplateFuncs.promptDefaults && defaultValue != nil { + return *defaultValue, nil + } + return c.readBool(prompt, defaultValue) +} + +// promptChoice prompts the user for one of choices until a valid choice is made. +func (c *Config) promptChoice(prompt string, choices []string, args ...string) (string, error) { + var defaultValue *string + switch len(args) { + case 0: + // Do nothing. + case 1: + if !slices.Contains(choices, args[0]) { + return "", fmt.Errorf("%s: invalid default value", args[0]) + } + defaultValue = &args[0] + default: + return "", fmt.Errorf("want 2 or 3 arguments, got %d", len(args)+2) + } + if c.interactiveTemplateFuncs.promptDefaults && defaultValue != nil { + return *defaultValue, nil + } + return c.readChoice(prompt, choices, defaultValue) +} + +func (c *Config) promptInt(prompt string, args ...int64) (int64, error) { + var defaultValue *int64 + switch len(args) { + case 0: + // Do nothing. + case 1: + defaultValue = &args[0] + default: + return 0, fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + } + if c.interactiveTemplateFuncs.promptDefaults && defaultValue != nil { + return *defaultValue, nil + } + return c.readInt(prompt, defaultValue) +} + +func (c *Config) promptString(prompt string, args ...string) (string, error) { + var defaultValue *string + switch len(args) { + case 0: + // Do nothing. + case 1: + arg := strings.TrimSpace(args[0]) + defaultValue = &arg + default: + return "", fmt.Errorf("want 1 or 2 arguments, got %d", len(args)+1) + } + if c.interactiveTemplateFuncs.promptDefaults && defaultValue != nil { + return *defaultValue, nil + } + return c.readString(prompt, defaultValue) +} + +type cancelableModel interface { + tea.Model + Canceled() bool +} + +func runCancelableModel[M cancelableModel](initModel M) (M, error) { + switch finalModel, err := runModel(initModel); { + case err != nil: + return finalModel, err + case finalModel.Canceled(): + return finalModel, chezmoi.ExitCodeError(0) + default: + return finalModel, nil + } +} + +func runModel[M tea.Model](initModel M) (M, error) { + program := tea.NewProgram(initModel) + finalModel, err := program.Run() + return finalModel.(M), err //nolint:forcetypeassert +} diff --git a/internal/cmd/purgecmd.go b/internal/cmd/purgecmd.go index fc1a99b94d3..69f9cb8f637 100644 --- a/internal/cmd/purgecmd.go +++ b/internal/cmd/purgecmd.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "os" + "runtime" "strings" "github.com/spf13/cobra" @@ -24,50 +25,79 @@ func (c *Config) newPurgeCmd() *cobra.Command { Example: example("purge"), Args: cobra.NoArgs, RunE: c.runPurgeCmd, - Annotations: map[string]string{ - modifiesDestinationDirectory: "true", - modifiesSourceDirectory: "true", - }, + Annotations: newAnnotations( + modifiesDestinationDirectory, + modifiesSourceDirectory, + ), } - flags := purgeCmd.Flags() - flags.BoolVarP(&c.purge.binary, "binary", "P", c.purge.binary, "Purge chezmoi binary") + purgeCmd.Flags().BoolVarP(&c.purge.binary, "binary", "P", c.purge.binary, "Purge chezmoi binary") return purgeCmd } func (c *Config) runPurgeCmd(cmd *cobra.Command, args []string) error { - return c.doPurge(&purgeOptions{ - binary: c.purge.binary, + return c.doPurge(&doPurgeOptions{ + binary: c.purge.binary, + cache: true, + config: true, + persistentState: true, + sourceDir: true, + workingTree: true, }) } // doPurge is the core purge functionality. It removes all files and directories // associated with chezmoi. -func (c *Config) doPurge(purgeOptions *purgeOptions) error { - if c.persistentState != nil { - if err := c.persistentState.Close(); err != nil { +func (c *Config) doPurge(options *doPurgeOptions) error { + // absPaths contains the list of paths to purge, in order. The order is + // assembled so that parent directories are purged before their children. + var absPaths []chezmoi.AbsPath + + if options.cache { + absPaths = append(absPaths, c.CacheDirAbsPath) + } + + if options.config { + absPaths = append(absPaths, c.getConfigFileAbsPath().Dir(), c.getConfigFileAbsPath()) + } + + if options.persistentState { + if c.persistentState != nil { + if err := c.persistentState.Close(); err != nil { + return err + } + } + + persistentStateFileAbsPath, err := c.persistentStateFile() + if err != nil { return err } + + absPaths = append(absPaths, persistentStateFileAbsPath) } - persistentStateFileAbsPath, err := c.persistentStateFile() - if err != nil { - return err + if options.workingTree { + absPaths = append(absPaths, c.WorkingTreeAbsPath) } - absPaths := []chezmoi.AbsPath{ - c.CacheDirAbsPath, - c.configFileAbsPath.Dir(), - c.configFileAbsPath, - persistentStateFileAbsPath, - c.WorkingTreeAbsPath, - c.SourceDirAbsPath, + + if options.sourceDir { + absPaths = append(absPaths, c.SourceDirAbsPath) } - if purgeOptions != nil && purgeOptions.binary { - executable, err := os.Executable() - // Special case: do not purge the binary if it is a test binary created - // by go test as this would break later tests. - if err == nil && !strings.Contains(executable, "test") { + + if options.binary { + switch executable, err := os.Executable(); { + case err != nil: + return err + case runtime.GOOS == "windows": + // On Windows the binary of a running process cannot be removed. + // Warn the user, but otherwise continue. + c.errorf("cannot purge binary (%s) on Windows\n", executable) + case strings.Contains(executable, "test"): + // Special case: do not purge the binary if it is a test binary created + // by go test as this will break later or concurrent tests. + default: + // Otherwise, remove the binary normally. absPaths = append(absPaths, chezmoi.NewAbsPath(executable)) } } diff --git a/internal/cmd/rbwtemplatefuncs.go b/internal/cmd/rbwtemplatefuncs.go new file mode 100644 index 00000000000..8434be7163c --- /dev/null +++ b/internal/cmd/rbwtemplatefuncs.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "encoding/json" + "os" + "os/exec" + "strings" + + "github.com/coreos/go-semver/semver" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +type rbwConfig struct { + Command string `json:"command" mapstructure:"command" yaml:"command"` + outputCache map[string][]byte +} + +var rbwMinVersion = semver.Version{Major: 1, Minor: 7, Patch: 0} + +func (c *Config) rbwFieldsTemplateFunc(name string, extraArgs ...string) map[string]any { + args := append([]string{"get", "--raw", name}, extraArgs...) + output, err := c.rbwOutput(args) + if err != nil { + panic(err) + } + var data struct { + Fields []map[string]any `json:"fields"` + } + if err := json.Unmarshal(output, &data); err != nil { + panic(newParseCmdOutputError(c.RBW.Command, args, output, err)) + } + result := make(map[string]any) + for _, field := range data.Fields { + if name, ok := field["name"].(string); ok { + result[name] = field + } + } + return result +} + +func (c *Config) rbwTemplateFunc(name string, extraArgs ...string) map[string]any { + args := append([]string{"get", "--raw", name}, extraArgs...) + output, err := c.rbwOutput(args) + if err != nil { + panic(err) + } + var data map[string]any + if err := json.Unmarshal(output, &data); err != nil { + panic(newParseCmdOutputError(c.RBW.Command, args, output, err)) + } + return data +} + +func (c *Config) rbwOutput(args []string) ([]byte, error) { + key := strings.Join(args, "\x00") + if data, ok := c.RBW.outputCache[key]; ok { + return data, nil + } + + cmd := exec.Command(c.RBW.Command, args...) //nolint:gosec + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) + if err != nil { + return nil, newCmdOutputError(cmd, output, err) + } + + if c.RBW.outputCache == nil { + c.RBW.outputCache = make(map[string][]byte) + } + c.RBW.outputCache[key] = output + return output, nil +} diff --git a/internal/cmd/readdcmd.go b/internal/cmd/readdcmd.go index ceeb3459114..edf0e365439 100644 --- a/internal/cmd/readdcmd.go +++ b/internal/cmd/readdcmd.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "fmt" "io/fs" "sort" @@ -11,30 +12,29 @@ import ( ) type reAddCmdConfig struct { - exclude *chezmoi.EntryTypeSet - include *chezmoi.EntryTypeSet + filter *chezmoi.EntryTypeFilter recursive bool } func (c *Config) newReAddCmd() *cobra.Command { reAddCmd := &cobra.Command{ - Use: "re-add [targets...]", - Short: "Re-add modified files", - Long: mustLongHelp("re-add"), - Example: example("re-add"), - Args: cobra.ArbitraryArgs, - RunE: c.makeRunEWithSourceState(c.runReAddCmd), - Annotations: map[string]string{ - modifiesSourceDirectory: "true", - persistentStateMode: persistentStateModeReadWrite, - requiresSourceDirectory: "true", - }, + Use: "re-add", + Short: "Re-add modified files", + Long: mustLongHelp("re-add"), + Example: example("re-add"), + ValidArgsFunction: c.targetValidArgs, + Args: cobra.ArbitraryArgs, + RunE: c.makeRunEWithSourceState(c.runReAddCmd), + Annotations: newAnnotations( + modifiesSourceDirectory, + persistentStateModeReadWrite, + requiresSourceDirectory, + ), } - flags := reAddCmd.Flags() - flags.VarP(c.reAdd.exclude, "exclude", "x", "Exclude entry types") - flags.VarP(c.reAdd.include, "include", "i", "Include entry types") - flags.BoolVarP(&c.reAdd.recursive, "recursive", "r", c.reAdd.recursive, "Recurse into subdirectories") + reAddCmd.Flags().VarP(c.reAdd.filter.Exclude, "exclude", "x", "Exclude entry types") + reAddCmd.Flags().VarP(c.reAdd.filter.Include, "include", "i", "Include entry types") + reAddCmd.Flags().BoolVarP(&c.reAdd.recursive, "recursive", "r", c.reAdd.recursive, "Recurse into subdirectories") return reAddCmd } @@ -42,13 +42,29 @@ func (c *Config) newReAddCmd() *cobra.Command { func (c *Config) runReAddCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { var targetRelPaths chezmoi.RelPaths sourceStateEntries := make(map[chezmoi.RelPath]chezmoi.SourceStateEntry) - _ = sourceState.ForEach(func(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi.SourceStateEntry) error { - targetRelPaths = append(targetRelPaths, targetRelPath) - sourceStateEntries[targetRelPath] = sourceStateEntry - return nil - }) + if len(args) == 0 { + _ = sourceState.ForEach( + func(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi.SourceStateEntry) error { + targetRelPaths = append(targetRelPaths, targetRelPath) + sourceStateEntries[targetRelPath] = sourceStateEntry + return nil + }, + ) + } else { + var err error + targetRelPaths, err = c.targetRelPaths(sourceState, args, &targetRelPathsOptions{ + recursive: c.reAdd.recursive, + }) + if err != nil { + return err + } + for _, targetRelPath := range targetRelPaths { + sourceStateEntries[targetRelPath] = sourceState.Get(targetRelPath) + } + } sort.Sort(targetRelPaths) +TARGET_REL_PATH: for _, targetRelPath := range targetRelPaths { sourceStateFile, ok := sourceStateEntries[targetRelPath].(*chezmoi.SourceStateFile) if !ok { @@ -93,14 +109,45 @@ func (c *Config) runReAddCmd(cmd *cobra.Command, args []string, sourceState *che continue } + if c.interactive { + prompt := fmt.Sprintf("Re-add %s", targetRelPath) + var choices []string + if actualContents != nil || targetContents != nil { + choices = append(choices, "diff") + } + choices = append(choices, choicesYesNoAllQuit...) + FOR: + for { + switch choice, err := c.promptChoice(prompt, choices); { + case err != nil: + return err + case choice == "diff": + if err := c.diffFile(targetRelPath, targetContents, targetStateFile.Perm(c.Umask), actualContents, actualStateFile.Perm()); err != nil { + return err + } + case choice == "yes": + break FOR + case choice == "no": + continue TARGET_REL_PATH + case choice == "all": + c.interactive = false + break FOR + case choice == "quit": + return chezmoi.ExitCodeError(0) + default: + panic(choice + ": unexpected choice") + } + } + } + destAbsPathInfos := map[chezmoi.AbsPath]fs.FileInfo{ destAbsPath: destAbsPathInfo, } if err := sourceState.Add(c.sourceSystem, c.persistentState, c.destSystem, destAbsPathInfos, &chezmoi.AddOptions{ - Empty: sourceStateFile.Attr.Empty, Encrypt: sourceStateFile.Attr.Encrypted, EncryptedSuffix: c.encryption.EncryptedSuffix(), - Include: c.reAdd.include.Sub(c.reAdd.exclude), + Errorf: c.errorf, + Filter: c.reAdd.filter, }); err != nil { return err } diff --git a/internal/cmd/readhttpresponse.go b/internal/cmd/readhttpresponse.go new file mode 100644 index 00000000000..a36f70ebcfb --- /dev/null +++ b/internal/cmd/readhttpresponse.go @@ -0,0 +1,202 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "time" + + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +const httpProgressWidth = 38 + +type httpProgressModel struct { + url string + contentLength int + progress progress.Model + canceled bool +} + +type httpSpinnerModel struct { + url string + spinner spinner.Model + canceled bool +} + +type bytesReadMsg int + +type doneMsg struct { + err error +} + +func (m httpProgressModel) Canceled() bool { + return m.canceled +} + +func (m httpProgressModel) Init() tea.Cmd { + return nil +} + +func (m httpProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case bytesReadMsg: + cmd := m.progress.SetPercent(float64(msg) / float64(m.contentLength)) + return m, cmd + case doneMsg: + return m, tea.Quit + case progress.FrameMsg: + model, cmd := m.progress.Update(msg) + m.progress = model.(progress.Model) //nolint:forcetypeassert + return m, cmd + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + default: + return m, nil + } + default: + return m, nil + } +} + +func (m httpProgressModel) View() string { + return "[" + m.progress.View() + "] " + m.url +} + +func (m httpSpinnerModel) Canceled() bool { + return m.canceled +} + +func (m httpSpinnerModel) Init() tea.Cmd { + return nil +} + +func (m httpSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case bytesReadMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(m.spinner.Tick()) + return m, cmd + case doneMsg: + return m, tea.Quit + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.canceled = true + return m, tea.Quit + default: + return m, nil + } + default: + return m, nil + } +} + +func (m httpSpinnerModel) View() string { + return "[" + m.spinner.View() + "] " + m.url +} + +func (c *Config) readHTTPResponse(resp *http.Response) ([]byte, error) { + switch { + case c.noTTY || !c.Progress.Value(c.progressAutoFunc): + return io.ReadAll(resp.Body) + + case resp.ContentLength >= 0: + progress := progress.New( + progress.WithWidth(httpProgressWidth), + ) + progress.Full = '#' + progress.FullColor = "" + progress.Empty = ' ' + progress.EmptyColor = "" + progress.ShowPercentage = false + + model := httpProgressModel{ + url: resp.Request.URL.String(), + contentLength: int(resp.ContentLength), + progress: progress, + } + + return runReadHTTPResponse(model, resp) + + default: + spinner := spinner.New( + spinner.WithSpinner(spinner.Spinner{ + Frames: makeNightriderFrames("+", ' ', httpProgressWidth), + FPS: time.Second / 60, + }), + ) + + model := httpSpinnerModel{ + url: resp.Request.URL.String(), + spinner: spinner, + } + + return runReadHTTPResponse(model, resp) + } +} + +type hookWriter struct { + onWrite func([]byte) (int, error) +} + +func (w *hookWriter) Write(p []byte) (int, error) { + return w.onWrite(p) +} + +func runReadHTTPResponse(model cancelableModel, resp *http.Response) ([]byte, error) { + program := tea.NewProgram(model) + + bytesRead := 0 + hookWriter := &hookWriter{ + onWrite: func(p []byte) (int, error) { + bytesRead += len(p) + program.Send(bytesReadMsg(bytesRead)) + return len(p), nil + }, + } + + var data []byte + var err error + go func() { + data, err = io.ReadAll(io.TeeReader(resp.Body, hookWriter)) + program.Send(doneMsg{ + err: err, + }) + }() + + if model, err := program.Run(); err != nil { + return nil, err + } else if model.(cancelableModel).Canceled() { //nolint:forcetypeassert + return nil, chezmoi.ExitCodeError(0) + } + + return data, err +} + +func makeNightriderFrames(shape string, padding rune, width int) []string { + delta := width - len(shape) + if delta <= 0 { + return []string{shape[:width]} + } + frames := make([]string, 2*delta) + paddingStr := string([]rune{padding}) + for i := 0; i <= delta; i++ { + frames[i] = strings.Repeat(paddingStr, i) + shape + strings.Repeat(paddingStr, delta-i) + } + for i := delta + 1; i < 2*delta; i++ { + frames[i] = frames[2*delta-i] + } + return frames +} diff --git a/internal/cmd/readhttpresponse_test.go b/internal/cmd/readhttpresponse_test.go new file mode 100644 index 00000000000..311724b487f --- /dev/null +++ b/internal/cmd/readhttpresponse_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "strconv" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestMakeNightriderFrames(t *testing.T) { + for i, tc := range []struct { + shape string + padding rune + width int + expected []string + }{ + { + shape: "+", + padding: ' ', + width: 1, + expected: []string{ + "+", + }, + }, + { + shape: "+", + padding: ' ', + width: 2, + expected: []string{ + "+ ", + " +", + }, + }, + { + shape: "+", + padding: ' ', + width: 3, + expected: []string{ + "+ ", + " + ", + " +", + " + ", + }, + }, + { + shape: "<=>", + padding: ' ', + width: 1, + expected: []string{ + "<", + }, + }, + { + shape: "<=>", + padding: ' ', + width: 4, + expected: []string{ + "<=> ", + " <=>", + }, + }, + { + shape: "<=>", + padding: ' ', + width: 5, + expected: []string{ + "<=> ", + " <=> ", + " <=>", + " <=> ", + }, + }, + { + shape: "<=>", + padding: ' ', + width: 6, + expected: []string{ + "<=> ", + " <=> ", + " <=> ", + " <=>", + " <=> ", + " <=> ", + }, + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + actual := makeNightriderFrames(tc.shape, tc.padding, tc.width) + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/internal/cmd/removecmd.go b/internal/cmd/removecmd.go index e275ba8c8c6..6d0b3813bf7 100644 --- a/internal/cmd/removecmd.go +++ b/internal/cmd/removecmd.go @@ -1,77 +1,26 @@ package cmd import ( - "errors" - "fmt" - "io/fs" - "github.com/spf13/cobra" "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) -type removeCmdConfig struct { - recursive bool -} - 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", - persistentStateMode: persistentStateModeReadWrite, - }, + Deprecated: "remove has been removed, use forget or destroy instead", + Use: "remove", + Aliases: []string{"rm"}, + RunE: c.runRemoveCmd, + Long: mustLongHelp("remove"), + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), } - flags := removeCmd.Flags() - flags.BoolVarP(&c.remove.recursive, "recursive", "r", c.remove.recursive, "Recurse into subdirectories") - return removeCmd } -func (c *Config) runRemoveCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { - targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ - mustBeInSourceState: true, - recursive: c.remove.recursive, - }) - 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.promptChoice(fmt.Sprintf("Remove %s and %s", destAbsPath, sourceAbsPath), choicesYesNoAllQuit) - if err != nil { - return err - } - switch choice { - case "yes": - case "no": - continue - case "all": - c.force = true - case "quit": - return nil - } - } - if err := c.destSystem.RemoveAll(destAbsPath); err != nil && !errors.Is(err, fs.ErrNotExist) { - return err - } - if err := c.sourceSystem.RemoveAll(sourceAbsPath); err != nil && !errors.Is(err, fs.ErrNotExist) { - return err - } - if err := c.persistentState.Delete(chezmoi.EntryStateBucket, destAbsPath.Bytes()); err != nil { - return err - } - } - return nil +func (c *Config) runRemoveCmd(cmd *cobra.Command, args []string) error { + return chezmoi.ExitCodeError(1) } diff --git a/internal/cmd/secretcmd.go b/internal/cmd/secretcmd.go index a65239e3f62..e284668654f 100644 --- a/internal/cmd/secretcmd.go +++ b/internal/cmd/secretcmd.go @@ -2,6 +2,10 @@ package cmd import "github.com/spf13/cobra" +type secretCmdConfig struct { + keyring secretKeyringCmdConfig +} + func (c *Config) newSecretCmd() *cobra.Command { secretCmd := &cobra.Command{ Use: "secret", @@ -11,7 +15,9 @@ func (c *Config) newSecretCmd() *cobra.Command { Example: example("secret"), } - secretCmd.AddCommand(c.newSecretKeyringCmd()) + if secretKeyringCmd := c.newSecretKeyringCmd(); secretKeyringCmd != nil { + secretCmd.AddCommand(secretKeyringCmd) + } return secretCmd } diff --git a/internal/cmd/secretkeyringcmd.go b/internal/cmd/secretkeyringcmd.go index 0cc30f5e122..85c4b7b88a6 100644 --- a/internal/cmd/secretkeyringcmd.go +++ b/internal/cmd/secretkeyringcmd.go @@ -1,3 +1,5 @@ +//go:build !freebsd || (freebsd && cgo) + package cmd import ( @@ -6,52 +8,94 @@ import ( ) type secretKeyringCmdConfig struct { + delete secretKeyringDeleteCmdConfig + get secretKeyringGetCmdConfig + set secretKeyringSetCmdConfig +} + +type secretKeyringDeleteCmdConfig struct { + service string + user string +} + +type secretKeyringGetCmdConfig struct { + service string + user string +} + +type secretKeyringSetCmdConfig struct { service string user string value string } func (c *Config) newSecretKeyringCmd() *cobra.Command { - keyringCmd := &cobra.Command{ + secretKeyringCmd := &cobra.Command{ Use: "keyring", Args: cobra.NoArgs, Short: "Interact with keyring", } - persistentFlags := keyringCmd.PersistentFlags() - persistentFlags.StringVar(&c.secretKeyring.service, "service", "", "service") - persistentFlags.StringVar(&c.secretKeyring.user, "user", "", "user") - markPersistentFlagsRequired(keyringCmd, "service", "user") + secretKeyringDeleteCmd := &cobra.Command{ + Use: "delete", + Args: cobra.NoArgs, + Short: "Delete a value from keyring", + RunE: c.runSecretKeyringDeleteCmdE, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), + } + secretKeyringDeleteCmd.Flags().StringVar(&c.secret.keyring.delete.service, "service", "", "service") + secretKeyringDeleteCmd.Flags().StringVar(&c.secret.keyring.delete.user, "user", "", "user") + markFlagsRequired(secretKeyringDeleteCmd, "service", "user") + secretKeyringCmd.AddCommand(secretKeyringDeleteCmd) - keyringGetCmd := &cobra.Command{ + secretKeyringGetCmd := &cobra.Command{ Use: "get", Args: cobra.NoArgs, Short: "Get a value from keyring", - RunE: c.runKeyringGetCmdE, + RunE: c.runSecretKeyringGetCmdE, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), } - keyringCmd.AddCommand(keyringGetCmd) + secretKeyringGetCmd.Flags().StringVar(&c.secret.keyring.get.service, "service", "", "service") + secretKeyringGetCmd.Flags().StringVar(&c.secret.keyring.get.user, "user", "", "user") + markFlagsRequired(secretKeyringGetCmd, "service", "user") + secretKeyringCmd.AddCommand(secretKeyringGetCmd) - keyringSetCmd := &cobra.Command{ + secretKeyringSetCmd := &cobra.Command{ Use: "set", Args: cobra.NoArgs, Short: "Set a value in keyring", - RunE: c.runKeyringSetCmdE, + RunE: c.runSecretKeyringSetCmdE, + Annotations: newAnnotations( + doesNotRequireValidConfig, + ), } - keyringCmd.AddCommand(keyringSetCmd) + secretKeyringSetCmd.Flags().StringVar(&c.secret.keyring.set.service, "service", "", "service") + secretKeyringSetCmd.Flags().StringVar(&c.secret.keyring.set.user, "user", "", "user") + secretKeyringSetCmd.Flags().StringVar(&c.secret.keyring.set.value, "value", "", "value") + markFlagsRequired(secretKeyringSetCmd, "service", "user") + secretKeyringCmd.AddCommand(secretKeyringSetCmd) + + return secretKeyringCmd +} - return keyringCmd +func (c *Config) runSecretKeyringDeleteCmdE(cmd *cobra.Command, args []string) error { + return keyring.Delete(c.secret.keyring.delete.service, c.secret.keyring.delete.user) } -func (c *Config) runKeyringGetCmdE(cmd *cobra.Command, args []string) error { - value, err := keyring.Get(c.secretKeyring.service, c.secretKeyring.user) +func (c *Config) runSecretKeyringGetCmdE(cmd *cobra.Command, args []string) error { + value, err := keyring.Get(c.secret.keyring.get.service, c.secret.keyring.get.user) if err != nil { return err } return c.writeOutputString(value) } -func (c *Config) runKeyringSetCmdE(cmd *cobra.Command, args []string) error { - value := c.secretKeyring.value +func (c *Config) runSecretKeyringSetCmdE(cmd *cobra.Command, args []string) error { + value := c.secret.keyring.set.value if value == "" { var err error value, err = c.readPassword("Value: ") @@ -59,5 +103,5 @@ func (c *Config) runKeyringSetCmdE(cmd *cobra.Command, args []string) error { return err } } - return keyring.Set(c.secretKeyring.service, c.secretKeyring.user, value) + return keyring.Set(c.secret.keyring.set.service, c.secret.keyring.set.user, value) } diff --git a/internal/cmd/secretkeyringcmd_freebsdnocgo.go b/internal/cmd/secretkeyringcmd_freebsdnocgo.go new file mode 100644 index 00000000000..3da5dab0ceb --- /dev/null +++ b/internal/cmd/secretkeyringcmd_freebsdnocgo.go @@ -0,0 +1,11 @@ +//go:build freebsd && !cgo + +package cmd + +import "github.com/spf13/cobra" + +type secretKeyringCmdConfig struct{} + +func (c *Config) newSecretKeyringCmd() *cobra.Command { + return nil +} diff --git a/internal/cmd/secrettemplatefuncs.go b/internal/cmd/secrettemplatefuncs.go index 6c114185f14..b1dc4a3cf6e 100644 --- a/internal/cmd/secrettemplatefuncs.go +++ b/internal/cmd/secrettemplatefuncs.go @@ -3,61 +3,60 @@ package cmd import ( "bytes" "encoding/json" - "fmt" + "os" "os/exec" + "slices" "strings" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) type secretConfig struct { - Command string - cache map[string]string - jsonCache map[string]interface{} + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + cache map[string][]byte } -func (c *Config) secretJSONTemplateFunc(args ...string) interface{} { - key := strings.Join(args, "\x00") - if value, ok := c.Secret.jsonCache[key]; ok { - return value +func (c *Config) secretTemplateFunc(args ...string) string { + output, err := c.secretOutput(args) + if err != nil { + panic(err) } - name := c.Secret.Command - cmd := exec.Command(name, args...) - cmd.Stdin = c.stdin - cmd.Stderr = c.stderr - output, err := c.baseSystem.IdempotentCmdOutput(cmd) + return string(bytes.TrimSpace(output)) +} + +func (c *Config) secretJSONTemplateFunc(args ...string) any { + output, err := c.secretOutput(args) if err != nil { - returnTemplateError(fmt.Errorf("%s: %w\n%s", shellQuoteCommand(name, args), err, output)) - return nil + panic(err) } - var value interface{} + + var value any if err := json.Unmarshal(output, &value); err != nil { - returnTemplateError(fmt.Errorf("%s: %w\n%s", shellQuoteCommand(name, args), err, output)) - return nil + panic(newParseCmdOutputError(c.Secret.Command, args, output, err)) } - 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 { +func (c *Config) secretOutput(args []string) ([]byte, error) { key := strings.Join(args, "\x00") - if value, ok := c.Secret.cache[key]; ok { - return value + if output, ok := c.Secret.cache[key]; ok { + return output, nil } - name := c.Secret.Command - cmd := exec.Command(name, args...) - cmd.Stdin = c.stdin - cmd.Stderr = c.stderr - output, err := c.baseSystem.IdempotentCmdOutput(cmd) + + args = append(slices.Clone(c.Secret.Args), args...) + cmd := exec.Command(c.Secret.Command, args...) //nolint:gosec + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) if err != nil { - returnTemplateError(fmt.Errorf("%s: %w\n%s", shellQuoteCommand(name, args), err, output)) - return "" + return nil, newCmdOutputError(cmd, output, err) } - value := string(bytes.TrimSpace(output)) + if c.Secret.cache == nil { - c.Secret.cache = make(map[string]string) + c.Secret.cache = make(map[string][]byte) } - c.Secret.cache[key] = value - return value + c.Secret.cache[key] = output + + return output, nil } diff --git a/internal/cmd/severity.go b/internal/cmd/severity.go new file mode 100644 index 00000000000..c1906c56cb1 --- /dev/null +++ b/internal/cmd/severity.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +type severity string + +const ( + severityIgnore severity = "ignore" + severityWarning severity = "warning" + severityError severity = "error" +) + +var severityFlagCompletionFunc = chezmoi.FlagCompletionFunc([]string{ + "i", "ignore", + "w", "warning", + "e", "error", +}) + +// MarshalJSON implements encoding/json.Marshaler.MarshalJSON. +func (s severity) MarshalJSON() ([]byte, error) { + switch s { + case severityIgnore: + return []byte(`"ignore"`), nil + case severityWarning: + return []byte(`"warning"`), nil + case severityError: + return []byte(`"error"`), nil + default: + return []byte(`"unknown"`), nil + } +} + +// Set implements github.com/spf13/pflag.Value.Set. +func (s *severity) Set(str string) error { + switch strings.ToLower(str) { + case "i", "ignore": + *s = severityIgnore + case "w", "warning": + *s = severityWarning + case "e", "error": + *s = severityError + default: + return fmt.Errorf("%s: unknown severity", str) + } + return nil +} + +func (s *severity) String() string { + return string(*s) +} + +// Type implements github.com/spf13/pflag.Value.Type. +func (s *severity) Type() string { + return "ignore|warning|error" +} diff --git a/internal/cmd/shellquote.go b/internal/cmd/shellquote.go index b00a4afc046..95e3fd4ee7f 100644 --- a/internal/cmd/shellquote.go +++ b/internal/cmd/shellquote.go @@ -58,10 +58,10 @@ func shellQuoteCommand(command string, args []string) string { if len(args) == 0 { return shellQuote(command) } - elems := make([]string, 0, 1+len(args)) - elems = append(elems, shellQuote(command)) - for _, arg := range args { - elems = append(elems, shellQuote(arg)) + elems := make([]string, 1+len(args)) + elems[0] = shellQuote(command) + for i, arg := range args { + elems[i+1] = shellQuote(arg) } return strings.Join(elems, " ") } diff --git a/internal/cmd/shellquote_test.go b/internal/cmd/shellquote_test.go index b26306f8ff9..a975080a704 100644 --- a/internal/cmd/shellquote_test.go +++ b/internal/cmd/shellquote_test.go @@ -3,7 +3,7 @@ package cmd import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/alecthomas/assert/v2" ) func TestShellQuote(t *testing.T) { diff --git a/internal/cmd/sourcepathcmd.go b/internal/cmd/sourcepathcmd.go index 0d82f475ed5..cdda110a315 100644 --- a/internal/cmd/sourcepathcmd.go +++ b/internal/cmd/sourcepathcmd.go @@ -5,25 +5,34 @@ import ( "strings" "github.com/spf13/cobra" - - "github.com/twpayne/chezmoi/v2/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), + Use: "source-path [target]...", + Short: "Print the source path of a target", + Long: mustLongHelp("source-path"), + Example: example("source-path"), + ValidArgsFunction: c.targetValidArgs, + RunE: c.runSourcePathCmd, + Annotations: newAnnotations(), } return sourcePathCmd } -func (c *Config) runSourcePathCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { +func (c *Config) runSourcePathCmd(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return c.writeOutputString(c.SourceDirAbsPath.String() + "\n") + sourceDirAbsPath, err := c.getSourceDirAbsPath(nil) + if err != nil { + return err + } + return c.writeOutputString(sourceDirAbsPath.String() + "\n") + } + + sourceState, err := c.getSourceState(cmd.Context(), cmd) + if err != nil { + return err } sourceAbsPaths, err := c.sourceAbsPaths(sourceState, args) diff --git a/internal/cmd/statecmd.go b/internal/cmd/statecmd.go index c846efdbee4..6f2034d0cec 100644 --- a/internal/cmd/statecmd.go +++ b/internal/cmd/statecmd.go @@ -11,15 +11,11 @@ import ( ) type stateCmdConfig struct { - data stateDataCmdConfig - delete stateDeleteCmdConfig - dump stateDumpCmdConfig - get stateGetCmdConfig - set stateSetCmdConfig -} - -type stateDataCmdConfig struct { - format writeDataFormat + delete stateDeleteCmdConfig + deleteBucket stateDeleteBucketCmdConfig + get stateGetCmdConfig + getBucket stateGetBucketCmdConfig + set stateSetCmdConfig } type stateDeleteCmdConfig struct { @@ -27,8 +23,8 @@ type stateDeleteCmdConfig struct { key string } -type stateDumpCmdConfig struct { - format writeDataFormat +type stateDeleteBucketCmdConfig struct { + bucket string } type stateGetCmdConfig struct { @@ -36,6 +32,10 @@ type stateGetCmdConfig struct { key string } +type stateGetBucketCmdConfig struct { + bucket string +} + type stateSetCmdConfig struct { bucket string key string @@ -55,12 +55,11 @@ func (c *Config) newStateCmd() *cobra.Command { Short: "Print the raw data in the persistent state", Args: cobra.NoArgs, RunE: c.runStateDataCmd, - Annotations: map[string]string{ - persistentStateMode: persistentStateModeReadOnly, - }, + Annotations: newAnnotations( + persistentStateModeReadOnly, + ), } - stateDataPersistentFlags := stateDataCmd.PersistentFlags() - stateDataPersistentFlags.VarP(&c.state.data.format, "format", "f", "format") + stateDataCmd.Flags().VarP(&c.Format, "format", "f", "Output format") stateCmd.AddCommand(stateDataCmd) stateDeleteCmd := &cobra.Command{ @@ -68,26 +67,36 @@ func (c *Config) newStateCmd() *cobra.Command { Short: "Delete a value from the persistent state", Args: cobra.NoArgs, RunE: c.runStateDeleteCmd, - Annotations: map[string]string{ - persistentStateMode: persistentStateModeReadWrite, - }, + Annotations: newAnnotations( + persistentStateModeReadWrite, + ), } - stateDeletePersistentFlags := stateDeleteCmd.PersistentFlags() - stateDeletePersistentFlags.StringVar(&c.state.delete.bucket, "bucket", c.state.delete.bucket, "bucket") - stateDeletePersistentFlags.StringVar(&c.state.delete.key, "key", c.state.delete.key, "key") + stateDeleteCmd.Flags().StringVar(&c.state.delete.bucket, "bucket", c.state.delete.bucket, "Bucket") + stateDeleteCmd.Flags().StringVar(&c.state.delete.key, "key", c.state.delete.key, "Key") stateCmd.AddCommand(stateDeleteCmd) + stateDeleteBucketCmd := &cobra.Command{ + Use: "delete-bucket", + Short: "Delete a bucket from the persistent state", + Args: cobra.NoArgs, + RunE: c.runStateDeleteBucketCmd, + Annotations: newAnnotations( + persistentStateModeReadWrite, + ), + } + stateDeleteBucketCmd.Flags().StringVar(&c.state.deleteBucket.bucket, "bucket", c.state.deleteBucket.bucket, "Bucket") + stateCmd.AddCommand(stateDeleteBucketCmd) + stateDumpCmd := &cobra.Command{ Use: "dump", Short: "Generate a dump of the persistent state", Args: cobra.NoArgs, RunE: c.runStateDumpCmd, - Annotations: map[string]string{ - persistentStateMode: persistentStateModeReadOnly, - }, + Annotations: newAnnotations( + persistentStateModeReadOnly, + ), } - stateDumpPersistentFlags := stateDumpCmd.PersistentFlags() - stateDumpPersistentFlags.VarP(&c.state.dump.format, "format", "f", "format") + stateDumpCmd.Flags().VarP(&c.Format, "format", "f", "Output format") stateCmd.AddCommand(stateDumpCmd) stateGetCmd := &cobra.Command{ @@ -95,23 +104,35 @@ func (c *Config) newStateCmd() *cobra.Command { Short: "Get a value from the persistent state", Args: cobra.NoArgs, RunE: c.runStateGetCmd, - Annotations: map[string]string{ - persistentStateMode: persistentStateModeReadOnly, - }, + Annotations: newAnnotations( + persistentStateModeReadOnly, + ), } - stateGetPersistentFlags := stateGetCmd.PersistentFlags() - stateGetPersistentFlags.StringVar(&c.state.get.bucket, "bucket", c.state.get.bucket, "bucket") - stateGetPersistentFlags.StringVar(&c.state.get.key, "key", c.state.get.key, "key") + stateGetCmd.Flags().StringVar(&c.state.get.bucket, "bucket", c.state.get.bucket, "Bucket") + stateGetCmd.Flags().StringVar(&c.state.get.key, "key", c.state.get.key, "Key") stateCmd.AddCommand(stateGetCmd) + stateGetBucketCmd := &cobra.Command{ + Use: "get-bucket", + Short: "Get a bucket from the persistent state", + Args: cobra.NoArgs, + RunE: c.runStateGetBucketCmd, + Annotations: newAnnotations( + persistentStateModeReadOnly, + ), + } + stateGetBucketCmd.Flags().StringVar(&c.state.getBucket.bucket, "bucket", c.state.getBucket.bucket, "bucket") + stateGetBucketCmd.Flags().VarP(&c.Format, "format", "f", "Output format") + stateCmd.AddCommand(stateGetBucketCmd) + stateResetCmd := &cobra.Command{ Use: "reset", Short: "Reset the persistent state", Args: cobra.NoArgs, RunE: c.runStateResetCmd, - Annotations: map[string]string{ - modifiesDestinationDirectory: "true", - }, + Annotations: newAnnotations( + modifiesDestinationDirectory, + ), } stateCmd.AddCommand(stateResetCmd) @@ -120,14 +141,13 @@ func (c *Config) newStateCmd() *cobra.Command { Short: "Set a value from the persistent state", Args: cobra.NoArgs, RunE: c.runStateSetCmd, - Annotations: map[string]string{ - persistentStateMode: persistentStateModeReadWrite, - }, - } - stateSetPersistentFlags := stateSetCmd.PersistentFlags() - stateSetPersistentFlags.StringVar(&c.state.set.bucket, "bucket", c.state.set.bucket, "bucket") - stateSetPersistentFlags.StringVar(&c.state.set.key, "key", c.state.set.key, "key") - stateSetPersistentFlags.StringVar(&c.state.set.value, "value", c.state.set.value, "value") + Annotations: newAnnotations( + persistentStateModeReadWrite, + ), + } + stateSetCmd.Flags().StringVar(&c.state.set.bucket, "bucket", c.state.set.bucket, "Bucket") + stateSetCmd.Flags().StringVar(&c.state.set.key, "key", c.state.set.key, "Key") + stateSetCmd.Flags().StringVar(&c.state.set.value, "value", c.state.set.value, "Value") stateCmd.AddCommand(stateSetCmd) return stateCmd @@ -138,19 +158,32 @@ func (c *Config) runStateDataCmd(cmd *cobra.Command, args []string) error { if err != nil { return err } - return c.marshal(c.state.data.format, data) + return c.marshal(c.Format, data) } func (c *Config) runStateDeleteCmd(cmd *cobra.Command, args []string) error { return c.persistentState.Delete([]byte(c.state.delete.bucket), []byte(c.state.delete.key)) } +func (c *Config) runStateDeleteBucketCmd(cmd *cobra.Command, args []string) error { + return c.persistentState.DeleteBucket([]byte(c.state.deleteBucket.bucket)) +} + func (c *Config) runStateDumpCmd(cmd *cobra.Command, args []string) error { - data, err := chezmoi.PersistentStateData(c.persistentState) + data, err := chezmoi.PersistentStateData(c.persistentState, map[string][]byte{ + "configState": chezmoi.ConfigStateBucket, + "entryState": chezmoi.EntryStateBucket, + "gitHubKeysState": gitHubKeysStateBucket, + "gitHubLatestReleaseState": gitHubLatestReleaseStateBucket, + "gitHubReleasesState": gitHubReleasesStateBucket, + "gitHubTagsState": gitHubTagsStateBucket, + "gitRepoExternalState": chezmoi.GitRepoExternalStateBucket, + "scriptState": chezmoi.ScriptStateBucket, + }) if err != nil { return err } - return c.marshal(c.state.dump.format, data) + return c.marshal(c.Format, data) } func (c *Config) runStateGetCmd(cmd *cobra.Command, args []string) error { @@ -161,6 +194,14 @@ func (c *Config) runStateGetCmd(cmd *cobra.Command, args []string) error { return c.writeOutput(value) } +func (c *Config) runStateGetBucketCmd(cmd *cobra.Command, args []string) error { + data, err := chezmoi.PersistentStateBucketData(c.persistentState, []byte(c.state.getBucket.bucket)) + if err != nil { + return err + } + return c.marshal(c.Format, data) +} + func (c *Config) runStateResetCmd(cmd *cobra.Command, args []string) error { persistentStateFileAbsPath, err := c.persistentStateFile() if err != nil { @@ -173,11 +214,13 @@ func (c *Config) runStateResetCmd(cmd *cobra.Command, args []string) error { return err } if !c.force { - switch choice, err := c.promptChoice(fmt.Sprintf("Remove %s", persistentStateFileAbsPath), []string{"yes", "no"}); { + switch choice, err := c.promptChoice(fmt.Sprintf("Remove %s", persistentStateFileAbsPath), choicesYesNoQuit); { case err != nil: return err case choice == "yes": case choice == "no": + fallthrough + case choice == "quit": return nil } } diff --git a/internal/cmd/statuscmd.go b/internal/cmd/statuscmd.go index 7e9ffc4451c..9a199a35afa 100644 --- a/internal/cmd/statuscmd.go +++ b/internal/cmd/statuscmd.go @@ -1,16 +1,21 @@ package cmd import ( + "errors" "fmt" + "io/fs" + "log/slog" "strings" "github.com/spf13/cobra" "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) type statusCmdConfig struct { - exclude *chezmoi.EntryTypeSet + Exclude *chezmoi.EntryTypeSet `json:"exclude" mapstructure:"exclude" yaml:"exclude"` + PathStyle *chezmoi.PathStyle `json:"pathStyle" mapstructure:"pathStyle" yaml:"pathStyle"` include *chezmoi.EntryTypeSet init bool recursive bool @@ -18,38 +23,37 @@ type statusCmdConfig struct { func (c *Config) newStatusCmd() *cobra.Command { statusCmd := &cobra.Command{ - Use: "status [target]...", - Short: "Show the status of targets", - Long: mustLongHelp("status"), - Example: example("status"), - RunE: c.makeRunEWithSourceState(c.runStatusCmd), - Annotations: map[string]string{ - modifiesDestinationDirectory: "true", - persistentStateMode: persistentStateModeReadMockWrite, - }, + Use: "status [target]...", + Short: "Show the status of targets", + Long: mustLongHelp("status"), + Example: example("status"), + ValidArgsFunction: c.targetValidArgs, + RunE: c.runStatusCmd, + Annotations: newAnnotations( + dryRun, + persistentStateModeReadMockWrite, + requiresSourceDirectory, + ), } - flags := statusCmd.Flags() - flags.VarP(c.status.exclude, "exclude", "x", "Exclude entry types") - flags.VarP(c.status.include, "include", "i", "Include entry types") - flags.BoolVar(&c.status.init, "init", c.update.init, "Recreate config file from template") - flags.BoolVarP(&c.status.recursive, "recursive", "r", c.status.recursive, "Recurse into subdirectories") + statusCmd.Flags().VarP(c.Status.Exclude, "exclude", "x", "Exclude entry types") + statusCmd.Flags().VarP(c.Status.PathStyle, "path-style", "p", "Path style") + statusCmd.Flags().VarP(c.Status.include, "include", "i", "Include entry types") + statusCmd.Flags().BoolVar(&c.Status.init, "init", c.Status.init, "Recreate config file from template") + statusCmd.Flags().BoolVarP(&c.Status.recursive, "recursive", "r", c.Status.recursive, "Recurse into subdirectories") return statusCmd } -func (c *Config) runStatusCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { +func (c *Config) runStatusCmd(cmd *cobra.Command, args []string) error { builder := strings.Builder{} - dryRunSystem := chezmoi.NewDryRunSystem(c.destSystem) - preApplyFunc := func( - targetRelPath chezmoi.RelPath, targetEntryState, lastWrittenEntryState, actualEntryState *chezmoi.EntryState, - ) error { - c.logger.Info(). - Stringer("targetRelPath", targetRelPath). - Object("targetEntryState", targetEntryState). - Object("lastWrittenEntryState", lastWrittenEntryState). - Object("actualEntryState", actualEntryState). - Msg("statusPreApplyFunc") + preApplyFunc := func(targetRelPath chezmoi.RelPath, targetEntryState, lastWrittenEntryState, actualEntryState *chezmoi.EntryState) error { + c.logger.Info("statusPreApplyFunc", + chezmoilog.Stringer("targetRelPath", targetRelPath), + slog.Any("targetEntryState", targetEntryState), + slog.Any("lastWrittenEntryState", lastWrittenEntryState), + slog.Any("actualEntryState", actualEntryState), + ) var ( x = ' ' @@ -62,15 +66,29 @@ func (c *Config) runStatusCmd(cmd *cobra.Command, args []string, sourceState *ch x = statusRune(lastWrittenEntryState, actualEntryState) y = statusRune(actualEntryState, targetEntryState) } + if x != ' ' || y != ' ' { - fmt.Fprintf(&builder, "%c%c %s\n", x, y, targetRelPath) + var path string + switch *c.Status.PathStyle { + case chezmoi.PathStyleAbsolute: + path = c.DestDirAbsPath.Join(targetRelPath).String() + case chezmoi.PathStyleRelative: + path = targetRelPath.String() + case chezmoi.PathStyleSourceAbsolute: + return errors.New("source-absolute not supported for status") + case chezmoi.PathStyleSourceRelative: + return errors.New("source-relative not supported for status") + } + + fmt.Fprintf(&builder, "%c%c %s\n", x, y, path) } - return chezmoi.Skip + return fs.SkipDir } - if err := c.applyArgs(cmd.Context(), dryRunSystem, c.DestDirAbsPath, args, applyArgsOptions{ - include: c.status.include.Sub(c.status.exclude), - init: c.status.init, - recursive: c.status.recursive, + if err := c.applyArgs(cmd.Context(), c.destSystem, c.DestDirAbsPath, args, applyArgsOptions{ + cmd: cmd, + filter: chezmoi.NewEntryTypeFilter(c.Status.include.Bits(), c.Status.Exclude.Bits()), + init: c.Status.init, + recursive: c.Status.recursive, umask: c.Umask, preApplyFunc: preApplyFunc, }); err != nil { diff --git a/internal/cmd/statuscmd_test.go b/internal/cmd/statuscmd_test.go index 92fa53ea9a6..c8b68d5082f 100644 --- a/internal/cmd/statuscmd_test.go +++ b/internal/cmd/statuscmd_test.go @@ -4,10 +4,9 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - vfs "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + "github.com/alecthomas/assert/v2" + vfs "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -15,23 +14,23 @@ import ( func TestStatusCmd(t *testing.T) { for _, tc := range []struct { name string - root interface{} + root any args []string - postApplyTests []interface{} + postApplyTests []any stdoutStr string }{ { name: "add_file", - root: map[string]interface{}{ + root: map[string]any{ "/home/user/.local/share/chezmoi/dot_bashrc": "# contents of .bashrc\n", }, args: []string{"~/.bashrc"}, stdoutStr: chezmoitest.JoinLines( ` A .bashrc`, ), - postApplyTests: []interface{}{ + postApplyTests: []any{ vfst.TestPath("/home/user/.local/share/chezmoi/dot_bashrc", - vfst.TestModeIsRegular, + vfst.TestModeIsRegular(), vfst.TestModePerm(0o666&^chezmoitest.Umask), vfst.TestContentsString("# contents of .bashrc\n"), ), @@ -39,12 +38,12 @@ func TestStatusCmd(t *testing.T) { }, { name: "update_symlink", - root: map[string]interface{}{ + root: map[string]any{ "/home/user/.symlink": &vfst.Symlink{Target: "old-target"}, "/home/user/.local/share/chezmoi/symlink_dot_symlink": "new-target\n", }, args: []string{"~/.symlink"}, - postApplyTests: []interface{}{ + postApplyTests: []any{ vfst.TestPath("/home/user/.symlink", vfst.TestSymlinkTarget("new-target"), ), @@ -57,15 +56,18 @@ func TestStatusCmd(t *testing.T) { t.Run(tc.name, func(t *testing.T) { chezmoitest.WithTestFS(t, tc.root, func(fileSystem vfs.FS) { stdout := strings.Builder{} - require.NoError(t, newTestConfig(t, fileSystem, withStdout(&stdout)).execute(append([]string{"status"}, tc.args...))) + config1 := newTestConfig(t, fileSystem, withStdout(&stdout)) + assert.NoError(t, config1.execute(append([]string{"status"}, tc.args...))) assert.Equal(t, tc.stdoutStr, stdout.String()) - require.NoError(t, newTestConfig(t, fileSystem).execute(append([]string{"apply"}, tc.args...))) + config2 := newTestConfig(t, fileSystem) + assert.NoError(t, config2.execute(append([]string{"apply"}, tc.args...))) vfst.RunTests(t, fileSystem, "", tc.postApplyTests...) stdout.Reset() - require.NoError(t, newTestConfig(t, fileSystem, withStdout(&stdout)).execute(append([]string{"status"}, tc.args...))) - assert.Empty(t, stdout.String()) + config3 := newTestConfig(t, fileSystem, withStdout(&stdout)) + assert.NoError(t, config3.execute(append([]string{"status"}, tc.args...))) + assert.Zero(t, stdout.String()) }) }) } diff --git a/internal/cmd/symlinks_test.go b/internal/cmd/symlinks_test.go index 8272108900a..6893c5bc088 100644 --- a/internal/cmd/symlinks_test.go +++ b/internal/cmd/symlinks_test.go @@ -4,9 +4,9 @@ import ( "io/fs" "testing" - "github.com/stretchr/testify/require" - "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -14,17 +14,17 @@ import ( func TestSymlinks(t *testing.T) { for _, tc := range []struct { name string - extraRoot interface{} + extraRoot any args []string - tests []interface{} + tests []any }{ { name: "symlink_forward_slash_unix", - extraRoot: map[string]interface{}{ + extraRoot: map[string]any{ "/home/user/.local/share/chezmoi/symlink_dot_symlink": ".dir/file", }, args: []string{"~/.symlink"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.symlink", vfst.TestModeType(fs.ModeSymlink), vfst.TestSymlinkTarget(".dir/file"), @@ -33,11 +33,11 @@ func TestSymlinks(t *testing.T) { }, { name: "symlink_forward_slash_windows", - extraRoot: map[string]interface{}{ + extraRoot: map[string]any{ "/home/user/.local/share/chezmoi/symlink_dot_symlink": ".dir/file", }, args: []string{"~/.symlink"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.symlink", vfst.TestModeType(fs.ModeSymlink), vfst.TestSymlinkTarget(".dir\\file"), @@ -46,11 +46,11 @@ func TestSymlinks(t *testing.T) { }, { name: "symlink_backward_slash_windows", - extraRoot: map[string]interface{}{ + extraRoot: map[string]any{ "/home/user/.local/share/chezmoi/symlink_dot_symlink": ".dir\\file", }, args: []string{"~/.symlink"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.symlink", vfst.TestModeType(fs.ModeSymlink), vfst.TestSymlinkTarget(".dir\\file"), @@ -59,11 +59,11 @@ func TestSymlinks(t *testing.T) { }, { name: "symlink_mixed_slash_windows", - extraRoot: map[string]interface{}{ + extraRoot: map[string]any{ "/home/user/.local/share/chezmoi/symlink_dot_symlink": ".dir/subdir\\file", }, args: []string{"~/.symlink"}, - tests: []interface{}{ + tests: []any{ vfst.TestPath("/home/user/.symlink", vfst.TestModeType(fs.ModeSymlink), vfst.TestSymlinkTarget(".dir\\subdir\\file"), @@ -75,11 +75,11 @@ func TestSymlinks(t *testing.T) { chezmoitest.SkipUnlessGOOS(t, tc.name) chezmoitest.WithTestFS(t, nil, func(fileSystem vfs.FS) { if tc.extraRoot != nil { - require.NoError(t, vfst.NewBuilder().Build(fileSystem, tc.extraRoot)) + assert.NoError(t, vfst.NewBuilder().Build(fileSystem, tc.extraRoot)) } - require.NoError(t, newTestConfig(t, fileSystem).execute(append([]string{"apply"}, tc.args...))) + assert.NoError(t, newTestConfig(t, fileSystem).execute(append([]string{"apply"}, tc.args...))) vfst.RunTests(t, fileSystem, "", tc.tests) - require.NoError(t, newTestConfig(t, fileSystem).execute([]string{"verify"})) + assert.NoError(t, newTestConfig(t, fileSystem).execute([]string{"verify"})) }) }) } diff --git a/internal/cmd/targetpathcmd.go b/internal/cmd/targetpathcmd.go new file mode 100644 index 00000000000..a2d5789da89 --- /dev/null +++ b/internal/cmd/targetpathcmd.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +func (c *Config) newTargetPathCmd() *cobra.Command { + targetPathCmd := &cobra.Command{ + Use: "target-path [source-path]...", + Short: "Print the target path of a source path", + Long: mustLongHelp("target-path"), + Example: example("target-path"), + RunE: c.runTargetPathCmd, + Annotations: newAnnotations(), + } + + return targetPathCmd +} + +func (c *Config) runTargetPathCmd(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return c.writeOutputString(c.DestDirAbsPath.String() + "\n") + } + + builder := strings.Builder{} + + for _, arg := range args { + argAbsPath, err := chezmoi.NewAbsPathFromExtPath(arg, c.homeDirAbsPath) + if err != nil { + return err + } + + argRelPath, err := argAbsPath.TrimDirPrefix(c.sourceDirAbsPath) + if err != nil { + return err + } + + var sourceRelPath chezmoi.SourceRelPath + switch fileInfo, err := c.sourceSystem.Stat(argAbsPath); { + case err != nil: + return err + case fileInfo.IsDir(): + sourceRelPath = chezmoi.NewSourceRelDirPath(argRelPath.String()) + default: + sourceRelPath = chezmoi.NewSourceRelPath(argRelPath.String()) + } + + targetRelPath := sourceRelPath.TargetRelPath(c.encryption.EncryptedSuffix()) + + if _, err := builder.WriteString(c.DestDirAbsPath.String()); err != nil { + return err + } + if err := builder.WriteByte('/'); err != nil { + return err + } + if _, err := builder.WriteString(targetRelPath.String()); err != nil { + return err + } + if err := builder.WriteByte('\n'); err != nil { + return err + } + } + + return c.writeOutputString(builder.String()) +} diff --git a/internal/cmd/templatefuncs.go b/internal/cmd/templatefuncs.go index 4f722814923..998cbf0d40b 100644 --- a/internal/cmd/templatefuncs.go +++ b/internal/cmd/templatefuncs.go @@ -1,54 +1,297 @@ package cmd import ( + "encoding/hex" + "encoding/json" "errors" "fmt" + "io" "io/fs" + "os" "os/exec" - "path" "path/filepath" + "regexp" "runtime" + "slices" + "strconv" + "strings" "github.com/bradenhilton/mozillainstallhash" + "github.com/itchyny/gojq" + "gopkg.in/ini.v1" "howett.net/plist" "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" + "github.com/twpayne/chezmoi/v2/internal/chezmoimaps" ) type ioregData struct { - value map[string]interface{} + value map[string]any } -func (c *Config) fromYamlTemplateFunc(str string) interface{} { - var result interface{} +// An emptyPathElementError is returned when a path element is empty. +type emptyPathElementError struct { + index int +} - if err := chezmoi.FormatYAML.Unmarshal([]byte(str), &result); err != nil { - returnTemplateError(err) - return nil +func (e emptyPathElementError) Error() string { + return fmt.Sprintf("empty path element at index %d", e.index) +} + +// An invalidPathElementTypeError is returned when an element in a path has an invalid type. +type invalidPathElementTypeError struct { + element any +} + +func (e invalidPathElementTypeError) Error() string { + return fmt.Sprintf("%v: invalid path element type %T", e.element, e.element) +} + +// An invalidPathTypeError is returned when a path has an invalid type. +type invalidPathTypeError struct { + path any +} + +func (e invalidPathTypeError) Error() string { + return fmt.Sprintf("%v: invalid path type %T", e.path, e.path) +} + +// errEmptyPath is returned when a path is empty. +var errEmptyPath = errors.New("empty path") + +// needsQuoteRx matches any string that contains non-printable characters, +// double quotes, or a backslash. +var needsQuoteRx = regexp.MustCompile(`[^\x21\x23-\x5b\x5d-\x7e]`) + +func (c *Config) commentTemplateFunc(prefix, s string) string { + type stateType int + const ( + startOfLine stateType = iota + inLine + ) + + state := startOfLine + var builder strings.Builder + for _, r := range s { + switch state { + case startOfLine: + if _, err := builder.WriteString(prefix); err != nil { + panic(err) + } + if _, err := builder.WriteRune(r); err != nil { + panic(err) + } + if r != '\n' { + state = inLine + } + case inLine: + if _, err := builder.WriteRune(r); err != nil { + panic(err) + } + if r == '\n' { + state = startOfLine + } + } } - return result + return builder.String() } -func (c *Config) includeTemplateFunc(filename string) string { - var absPath chezmoi.AbsPath - if path.IsAbs(filename) { - var err error - absPath, err = chezmoi.NewAbsPathFromExtPath(filename, c.homeDirAbsPath) +func (c *Config) deleteValueAtPathTemplateFunc(path string, dict map[string]any) any { + keys, lastKey, err := keysFromPath(path) + if err != nil { + panic(err) + } + + currentMap := dict + for _, key := range keys { + value, ok := currentMap[key] + if !ok { + return dict + } + nestedMap, ok := value.(map[string]any) + if !ok { + return dict + } + currentMap = nestedMap + } + delete(currentMap, lastKey) + + return dict +} + +func (c *Config) eqFoldTemplateFunc(first, second string, more ...string) bool { + if strings.EqualFold(first, second) { + return true + } + for _, s := range more { + if strings.EqualFold(first, s) { + return true + } + } + return false +} + +func (c *Config) findExecutableTemplateFunc(file string, pathList any) string { + files := []string{file} + paths, err := anyToStringSlice(pathList) + if err != nil { + panic(fmt.Errorf("path list: %w", err)) + } + + switch path, err := chezmoi.FindExecutable(files, paths); { + case err == nil: + return path + default: + panic(err) + } +} + +func (c *Config) findOneExecutableTemplateFunc(fileList, pathList any) string { + files, err := anyToStringSlice(fileList) + if err != nil { + panic(fmt.Errorf("file list: %w", err)) + } + + paths, err := anyToStringSlice(pathList) + if err != nil { + panic(fmt.Errorf("path list: %w", err)) + } + + switch path, err := chezmoi.FindExecutable(files, paths); { + case err == nil: + return path + default: + panic(err) + } +} + +func (c *Config) fromIniTemplateFunc(s string) map[string]any { + file, err := ini.Load([]byte(s)) + if err != nil { + panic(err) + } + return iniFileToMap(file) +} + +// fromJsonTemplateFunc parses s as JSON and returns the result. In contrast to +// encoding/json, numbers are represented as int64s or float64s if possible. +// +//nolint:revive,stylecheck +func (c *Config) fromJsonTemplateFunc(s string) any { + var value any + if err := chezmoi.FormatJSON.Unmarshal([]byte(s), &value); err != nil { + panic(err) + } + return value +} + +// fromJsoncTemplateFunc parses s as JSONC and returns the result. In contrast +// to encoding/json, numbers are represented as int64s or float64s if possible. +func (c *Config) fromJsoncTemplateFunc(s string) any { + var value any + if err := chezmoi.FormatJSONC.Unmarshal([]byte(s), &value); err != nil { + panic(err) + } + return value +} + +func (c *Config) fromTomlTemplateFunc(s string) any { + var value map[string]any + if err := chezmoi.FormatTOML.Unmarshal([]byte(s), &value); err != nil { + panic(err) + } + return value +} + +func (c *Config) fromYamlTemplateFunc(s string) any { + var value any + if err := chezmoi.FormatYAML.Unmarshal([]byte(s), &value); err != nil { + panic(err) + } + return value +} + +func (c *Config) globTemplateFunc(pattern string) []string { + defer func() { + value := recover() + err := os.Chdir(c.commandDirAbsPath.String()) + if value != nil { + panic(value) + } if err != nil { - returnTemplateError(err) + panic(err) } - } else { - absPath = c.SourceDirAbsPath.JoinString(filename) + }() + + if err := os.Chdir(c.DestDirAbsPath.String()); err != nil { + panic(err) } - contents, err := c.fileSystem.ReadFile(absPath.String()) + + matches, err := chezmoi.Glob(c.fileSystem, filepath.ToSlash(pattern)) if err != nil { - returnTemplateError(err) - return "" + panic(err) + } + return matches +} + +func (c *Config) hexDecodeTemplateFunc(s string) string { + result, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return string(result) +} + +func (c *Config) hexEncodeTemplateFunc(s string) string { + return hex.EncodeToString([]byte(s)) +} + +func (c *Config) includeTemplateFunc(filename string) string { + searchDirAbsPaths := []chezmoi.AbsPath{c.SourceDirAbsPath} + contents, err := c.readFile(filename, searchDirAbsPaths) + if err != nil { + panic(err) } return string(contents) } -func (c *Config) ioregTemplateFunc() map[string]interface{} { +func (c *Config) includeTemplateTemplateFunc(filename string, args ...any) string { + var data any + switch len(args) { + case 0: + // Do nothing. + case 1: + data = args[0] + default: + panic(fmt.Errorf("expected 0 or 1 arguments, got %d", len(args))) + } + + searchDirAbsPaths := []chezmoi.AbsPath{ + c.SourceDirAbsPath.JoinString(chezmoi.TemplatesDirName), + c.SourceDirAbsPath, + } + contents, err := c.readFile(filename, searchDirAbsPaths) + if err != nil { + panic(err) + } + + templateOptions := chezmoi.TemplateOptions{ + Options: slices.Clone(c.Template.Options), + } + tmpl, err := chezmoi.ParseTemplate(filename, contents, c.templateFuncs, templateOptions) + if err != nil { + panic(err) + } + + result, err := tmpl.Execute(data) + if err != nil { + panic(err) + } + return string(result) +} + +func (c *Config) ioregTemplateFunc() map[string]any { if runtime.GOOS != "darwin" { return nil } @@ -57,17 +300,18 @@ func (c *Config) ioregTemplateFunc() map[string]interface{} { return c.ioregData.value } - cmd := exec.Command("ioreg", "-a", "-l") - output, err := c.baseSystem.IdempotentCmdOutput(cmd) + command := "ioreg" + args := []string{"-a", "-l"} + cmd := exec.Command(command, args...) + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) if err != nil { - returnTemplateError(fmt.Errorf("ioreg: %w", err)) - return nil + panic(newCmdOutputError(cmd, output, err)) } - var value map[string]interface{} + var value map[string]any if _, err := plist.Unmarshal(output, &value); err != nil { - returnTemplateError(fmt.Errorf("ioreg: %w", err)) - return nil + panic(newParseCmdOutputError(command, args, output, err)) } c.ioregData.value = value return value @@ -77,8 +321,32 @@ func (c *Config) joinPathTemplateFunc(elem ...string) string { return filepath.Join(elem...) } +func (c *Config) jqTemplateFunc(source string, input any) any { + query, err := gojq.Parse(source) + if err != nil { + panic(err) + } + code, err := gojq.Compile(query) + if err != nil { + panic(err) + } + iter := code.Run(input) + var result []any + for { + value, ok := iter.Next() + if !ok { + break + } + if err, ok := value.(error); ok { + panic(err) + } + result = append(result, value) + } + return result +} + func (c *Config) lookPathTemplateFunc(file string) string { - switch path, err := exec.LookPath(file); { + switch path, err := chezmoi.LookPath(file); { case err == nil: return path case errors.Is(err, exec.ErrNotFound): @@ -86,59 +354,388 @@ func (c *Config) lookPathTemplateFunc(file string) string { case errors.Is(err, fs.ErrNotExist): return "" default: - returnTemplateError(err) - return "" + panic(err) + } +} + +func (c *Config) isExecutableTemplateFunc(file string) bool { + switch fileInfo, err := c.fileSystem.Stat(file); { + case err == nil: + return chezmoi.IsExecutable(fileInfo) + case errors.Is(err, fs.ErrNotExist): + return false + default: + panic(err) + } +} + +func (c *Config) lstatTemplateFunc(name string) any { + switch fileInfo, err := c.fileSystem.Lstat(name); { + case err == nil: + return fileInfoToMap(fileInfo) + case errors.Is(err, fs.ErrNotExist): + return nil + default: + panic(err) } } func (c *Config) mozillaInstallHashTemplateFunc(path string) string { mozillaInstallHash, err := mozillainstallhash.MozillaInstallHash(path) if err != nil { - returnTemplateError(err) - return "" + panic(err) } return mozillaInstallHash } func (c *Config) outputTemplateFunc(name string, args ...string) string { - output, err := c.baseSystem.IdempotentCmdOutput(exec.Command(name, args...)) + cmd := exec.Command(name, args...) + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) if err != nil { - returnTemplateError(err) - return "" + panic(newCmdOutputError(cmd, output, err)) } - // FIXME we should be able to return output directly, but - // github.com/Masterminds/sprig's trim function only accepts strings return string(output) } -func (c *Config) statTemplateFunc(name string) interface{} { +func (c *Config) pruneEmptyDictsTemplateFunc(dict map[string]any) map[string]any { + pruneEmptyMaps(dict) + return dict +} + +func (c *Config) quoteListTemplateFunc(list []any) []string { + result := make([]string, len(list)) + for i, elem := range list { + var elemStr string + switch elem := elem.(type) { + case []byte: + elemStr = string(elem) + case string: + elemStr = elem + case error: + elemStr = elem.Error() + case fmt.Stringer: + elemStr = elem.String() + default: + elemStr = fmt.Sprintf("%v", elem) + } + result[i] = strconv.Quote(elemStr) + } + return result +} + +func (c *Config) readFile(filename string, searchDirAbsPaths []chezmoi.AbsPath) ([]byte, error) { + if filepath.IsAbs(filename) { + absPath, err := chezmoi.NewAbsPathFromExtPath(filename, c.homeDirAbsPath) + if err != nil { + return nil, err + } + return c.fileSystem.ReadFile(absPath.String()) + } + + var data []byte + var err error + for _, searchDir := range searchDirAbsPaths { + data, err = c.fileSystem.ReadFile(searchDir.JoinString(filename).String()) + if !errors.Is(err, fs.ErrNotExist) { + return data, err + } + } + return data, err +} + +func (c *Config) replaceAllRegexTemplateFunc(expr, repl, s string) string { + return regexp.MustCompile(expr).ReplaceAllString(s, repl) +} + +func (c *Config) setValueAtPathTemplateFunc(path, value, dict any) any { + keys, lastKey, err := keysFromPath(path) + if err != nil { + panic(err) + } + + result, ok := dict.(map[string]any) + if !ok { + result = make(map[string]any) + } + + currentMap := result + for _, key := range keys { + if value, ok := currentMap[key]; ok { + if nestedMap, ok := value.(map[string]any); ok { + currentMap = nestedMap + } else { + nestedMap := make(map[string]any) + currentMap[key] = nestedMap + currentMap = nestedMap + } + } else { + nestedMap := make(map[string]any) + currentMap[key] = nestedMap + currentMap = nestedMap + } + } + currentMap[lastKey] = value + + return result +} + +func (c *Config) splitListTemplateFunc(sep, s string) []any { + strSlice := strings.Split(s, sep) + result := make([]interface{}, len(strSlice)) + for i, v := range strSlice { + result[i] = v + } + return result +} + +func (c *Config) statTemplateFunc(name string) any { switch fileInfo, err := c.fileSystem.Stat(name); { case err == nil: - return map[string]interface{}{ - "name": fileInfo.Name(), - "size": fileInfo.Size(), - "mode": int(fileInfo.Mode()), - "perm": int(fileInfo.Mode().Perm()), - "modTime": fileInfo.ModTime().Unix(), - "isDir": fileInfo.IsDir(), - } + return fileInfoToMap(fileInfo) case errors.Is(err, fs.ErrNotExist): return nil default: - returnTemplateError(err) - return nil + panic(err) + } +} + +func (c *Config) toIniTemplateFunc(data map[string]any) string { + var builder strings.Builder + if err := writeIniMap(&builder, data, ""); err != nil { + panic(err) } + return builder.String() } -func (c *Config) toYamlTemplateFunc(data interface{}) string { +func (c *Config) toPrettyJsonTemplateFunc(args ...any) string { //nolint:revive,stylecheck + var ( + indent = " " + value any + ) + switch len(args) { + case 1: + value = args[0] + case 2: + var ok bool + indent, ok = args[0].(string) + if !ok { + panic(fmt.Errorf("arg 1: expected a string, got a %T", args[0])) + } + value = args[1] + default: + panic(fmt.Errorf("expected 1 or 2 arguments, got %d", len(args))) + } + + var builder strings.Builder + encoder := json.NewEncoder(&builder) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", indent) + if err := encoder.Encode(value); err != nil { + panic(err) + } + return builder.String() +} + +func (c *Config) toTomlTemplateFunc(data any) string { + toml, err := chezmoi.FormatTOML.Marshal(data) + if err != nil { + panic(err) + } + return string(toml) +} + +func (c *Config) toYamlTemplateFunc(data any) string { yaml, err := chezmoi.FormatYAML.Marshal(data) if err != nil { - returnTemplateError(err) - return "" + panic(err) } return string(yaml) } -func returnTemplateError(err error) { - panic(err) +func fileInfoToMap(fileInfo fs.FileInfo) map[string]any { + return map[string]any{ + "name": fileInfo.Name(), + "size": fileInfo.Size(), + "mode": int(fileInfo.Mode()), + "perm": int(fileInfo.Mode().Perm()), + "modTime": fileInfo.ModTime().Unix(), + "isDir": fileInfo.IsDir(), + "type": chezmoi.FileModeTypeNames[fileInfo.Mode()&fs.ModeType], + } +} + +func iniFileToMap(file *ini.File) map[string]any { + m := make(map[string]any) + for _, section := range file.Sections() { + if section.Name() == ini.DefaultSection { + for _, k := range section.Keys() { + m[k.Name()] = k.Value() + } + } else { + m[section.Name()] = iniSectionToMap(section) + } + } + return m +} + +func iniSectionToMap(section *ini.Section) map[string]any { + m := make(map[string]any) + for _, s := range section.ChildSections() { + m[s.Name()] = iniSectionToMap(s) + } + for _, k := range section.Keys() { + m[k.Name()] = k.Value() + } + return m +} + +func keysFromPath(path any) ([]string, string, error) { + switch path := path.(type) { + case string: + if path == "" { + return nil, "", errEmptyPath + } + keys := strings.Split(path, ".") + for i, key := range keys { + if key == "" { + return nil, "", emptyPathElementError{ + index: i, + } + } + } + return keys[:len(keys)-1], keys[len(keys)-1], nil + case []any: + if len(path) == 0 { + return nil, "", errEmptyPath + } + keys := make([]string, len(path)) + for i, pathElement := range path { + switch pathElementStr, ok := pathElement.(string); { + case !ok: + return nil, "", invalidPathElementTypeError{ + element: pathElement, + } + case pathElementStr == "": + return nil, "", emptyPathElementError{ + index: i, + } + default: + keys[i] = pathElementStr + } + } + return keys[:len(keys)-1], keys[len(keys)-1], nil + case []string: + if len(path) == 0 { + return nil, "", errEmptyPath + } + for i, key := range path { + if key == "" { + return nil, "", emptyPathElementError{ + index: i, + } + } + } + return path[:len(path)-1], path[len(path)-1], nil + case nil: + return nil, "", errEmptyPath + default: + return nil, "", invalidPathTypeError{ + path: path, + } + } +} + +func nestedMapAtPath(m map[string]any, path any) (map[string]any, string, error) { + keys, lastKey, err := keysFromPath(path) + if err != nil { + return nil, "", err + } + for _, key := range keys { + nestedMap, ok := m[key].(map[string]any) + if !ok { + return nil, "", nil + } + m = nestedMap + } + return m, lastKey, nil +} + +func writeIniMap(w io.Writer, data map[string]any, sectionPrefix string) error { + // Write keys in order and accumulate subsections. + type subsection struct { + key string + value map[string]any + } + var subsections []subsection + for _, key := range chezmoimaps.SortedKeys(data) { + switch value := data[key].(type) { + case bool: + fmt.Fprintf(w, "%s = %t\n", key, value) + case float32, float64: + fmt.Fprintf(w, "%s = %f\n", key, value) + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr: + fmt.Fprintf(w, "%s = %d\n", key, value) + case map[string]any: + subsection := subsection{ + key: key, + value: value, + } + subsections = append(subsections, subsection) + case string: + fmt.Fprintf(w, "%s = %s\n", key, maybeQuote(value)) + default: + return fmt.Errorf("%s%s: %T: unsupported type", sectionPrefix, key, value) + } + } + + // Write subsections in order. + for _, subsection := range subsections { + if _, err := fmt.Fprintf(w, "\n[%s%s]\n", sectionPrefix, subsection.key); err != nil { + return err + } + if err := writeIniMap(w, subsection.value, sectionPrefix+subsection.key+"."); err != nil { + return err + } + } + + return nil +} + +func maybeQuote(s string) string { + if needsQuote(s) { + return strconv.Quote(s) + } + return s +} + +func needsQuote(s string) bool { + if s == "" { + return true + } + if needsQuoteRx.MatchString(s) { + return true + } + if _, err := strconv.ParseBool(s); err == nil { + return true + } + if _, err := strconv.ParseFloat(s, 64); err == nil { + return true + } + return false +} + +// pruneEmptyMaps prunes all empty maps from m and returns if m is now empty +// itself. +func pruneEmptyMaps(m map[string]any) bool { + for key, value := range m { + nestedMap, ok := value.(map[string]any) + if !ok { + continue + } + if pruneEmptyMaps(nestedMap) { + delete(m, key) + } + } + return len(m) == 0 } diff --git a/internal/cmd/templatefuncs_test.go b/internal/cmd/templatefuncs_test.go new file mode 100644 index 00000000000..b48b00acd52 --- /dev/null +++ b/internal/cmd/templatefuncs_test.go @@ -0,0 +1,825 @@ +package cmd + +import ( + "errors" + "strconv" + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/twpayne/chezmoi/v2/internal/chezmoiassert" + "github.com/twpayne/chezmoi/v2/internal/chezmoitest" +) + +func TestCommentTemplateFunc(t *testing.T) { + prefix := "# " + for i, tc := range []struct { + s string + expected string + }{ + { + s: "", + expected: "", + }, + { + s: "line", + expected: "# line", + }, + { + s: "\n", + expected: "# \n", + }, + { + s: "\n\n", + expected: "# \n# \n", + }, + { + s: "line1\nline2", + expected: "# line1\n# line2", + }, + { + s: "line1\nline2\n", + expected: "# line1\n# line2\n", + }, + { + s: "line1\n\nline3\n", + expected: "# line1\n# \n# line3\n", + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + c := &Config{} + assert.Equal(t, tc.expected, c.commentTemplateFunc(prefix, tc.s)) + }) + } +} + +func TestDeleteValueAtPathTemplateFunc(t *testing.T) { + for _, tc := range []struct { + name string + dict map[string]any + path string + expected any + expectedErr string + }{ + { + name: "empty", + expectedErr: "empty path", + }, + { + name: "outer", + dict: map[string]any{ + "key": "value", + }, + path: "key", + expected: map[string]any{}, + }, + { + name: "inner", + dict: map[string]any{ + "key1": map[string]any{ + "key2a": "value2a", + "key2b": "value2b", + }, + }, + path: "key1.key2a", + expected: map[string]any{ + "key1": map[string]any{ + "key2b": "value2b", + }, + }, + }, + { + name: "missing", + dict: map[string]any{ + "key": "value", + }, + path: "missingKey", + expected: map[string]any{ + "key": "value", + }, + }, + { + name: "missing_inner", + dict: map[string]any{ + "key1": map[string]any{ + "key2": 0, + }, + }, + path: "key1.key3", + expected: map[string]any{ + "key1": map[string]any{ + "key2": 0, + }, + }, + }, + { + name: "missing_depth2", + dict: map[string]any{ + "key1": map[string]any{ + "key2": map[string]any{ + "key3": 0, + }, + }, + }, + path: "key1.key2.missingKey", + expected: map[string]any{ + "key1": map[string]any{ + "key2": map[string]any{ + "key3": 0, + }, + }, + }, + }, + { + name: "not_an_inner_dict", + dict: map[string]any{ + "key1": map[string]any{ + "key2": 0, + }, + }, + path: "key1.key2.key3", + expected: map[string]any{ + "key1": map[string]any{ + "key2": 0, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var c Config + if tc.expectedErr == "" { + actual := c.deleteValueAtPathTemplateFunc(tc.path, tc.dict) + assert.Equal(t, tc.expected, actual) + } else { + chezmoiassert.PanicsWithErrorString(t, tc.expectedErr, func() { + c.deleteValueAtPathTemplateFunc(tc.path, tc.dict) + }) + } + }) + } +} + +func TestFromJson(t *testing.T) { + c, err := newConfig() + assert.NoError(t, err) + for i, tc := range []struct { + s string + expected any + }{ + { + s: `{"key":1}`, + expected: map[string]any{"key": int64(1)}, + }, + { + s: `{"key":2.2}`, + expected: map[string]any{"key": 2.2}, + }, + { + s: `{"key":[1,2.2,3]}`, + expected: map[string]any{"key": []any{int64(1), 2.2, int64(3)}}, + }, + { + s: `{"key":1}`, + expected: map[string]any{"key": int64(1)}, + }, + { + s: `{"key":1E400}`, + expected: map[string]any{"key": "1E400"}, + }, + { + s: `{"key":3.141592653589793238462643383279}`, + expected: map[string]any{"key": 3.141592653589793238462643383279}, + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + assert.Equal(t, tc.expected, c.fromJsonTemplateFunc(tc.s)) + }) + } +} + +func TestPruneEmptyDicts(t *testing.T) { + for _, tc := range []struct { + name string + dict map[string]any + expected map[string]any + }{ + { + name: "nil", + dict: nil, + expected: nil, + }, + { + name: "empty", + dict: map[string]any{}, + expected: map[string]any{}, + }, + { + name: "nested_empty", + dict: map[string]any{ + "key1": map[string]any{}, + "key2": 0, + }, + expected: map[string]any{ + "key2": 0, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, (&Config{}).pruneEmptyDictsTemplateFunc(tc.dict)) + }) + } +} + +func TestSetValueAtPathTemplateFunc(t *testing.T) { + for _, tc := range []struct { + name string + path any + value any + dict any + expected any + expectedErr string + }{ + { + name: "simple", + path: "key", + value: "value", + dict: make(map[string]any), + expected: map[string]any{ + "key": "value", + }, + }, + { + name: "create_map", + path: "key", + value: "value", + expected: map[string]any{ + "key": "value", + }, + }, + { + name: "modify_map", + path: "key2", + value: "value2", + dict: map[string]any{ + "key1": "value1", + }, + expected: map[string]any{ + "key1": "value1", + "key2": "value2", + }, + }, + { + name: "create_nested_map", + path: "key1.key2", + value: "value", + expected: map[string]any{ + "key1": map[string]any{ + "key2": "value", + }, + }, + }, + { + name: "modify_nested_map", + path: "key1.key2", + value: "value", + dict: map[string]any{ + "key1": map[string]any{ + "key2": "value2", + "key3": "value3", + }, + "key2": "value2", + }, + expected: map[string]any{ + "key1": map[string]any{ + "key2": "value", + "key3": "value3", + }, + "key2": "value2", + }, + }, + { + name: "replace_map", + path: "key1", + value: "value1", + dict: map[string]any{ + "key1": map[string]any{ + "key2": "value2", + }, + }, + expected: map[string]any{ + "key1": "value1", + }, + }, + { + name: "replace_nested_map", + path: "key1.key2", + value: "value2", + dict: map[string]any{ + "key1": map[string]any{ + "key2": map[string]any{ + "key3": "value3", + }, + }, + }, + expected: map[string]any{ + "key1": map[string]any{ + "key2": "value2", + }, + }, + }, + { + name: "replace_nested_value", + path: "key1.key2.key3", + value: "value3", + dict: map[string]any{ + "key1": map[string]any{ + "key2": "value2", + }, + }, + expected: map[string]any{ + "key1": map[string]any{ + "key2": map[string]any{ + "key3": "value3", + }, + }, + }, + }, + { + name: "string_list_path", + path: []string{ + "key1", + "key2", + }, + value: "value2", + expected: map[string]any{ + "key1": map[string]any{ + "key2": "value2", + }, + }, + }, + { + name: "any_list_path", + path: []any{ + "key1", + "key2", + }, + value: "value2", + expected: map[string]any{ + "key1": map[string]any{ + "key2": "value2", + }, + }, + }, + { + name: "invalid_path", + path: 0, + expectedErr: "0: invalid path type int", + }, + { + name: "invalid_path_element", + path: []any{ + 0, + }, + expectedErr: "0: invalid path element type int", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var c Config + if tc.expectedErr == "" { + actual := c.setValueAtPathTemplateFunc(tc.path, tc.value, tc.dict) + assert.Equal(t, tc.expected, actual) + } else { + chezmoiassert.PanicsWithErrorString(t, tc.expectedErr, func() { + c.setValueAtPathTemplateFunc(tc.path, tc.value, tc.dict) + }) + } + }) + } +} + +func TestFromIniTemplateFunc(t *testing.T) { + for i, tc := range []struct { + text string + expected map[string]any + }{ + { + text: chezmoitest.JoinLines( + `key = value`, + ), + expected: map[string]any{ + "key": "value", + }, + }, + { + text: chezmoitest.JoinLines( + `[section]`, + `sectionKey = sectionValue`, + ), + expected: map[string]any{ + "section": map[string]any{ + "sectionKey": "sectionValue", + }, + }, + }, + { + text: chezmoitest.JoinLines( + `key = value`, + `[section]`, + `sectionKey = sectionValue`, + ), + expected: map[string]any{ + "key": "value", + "section": map[string]any{ + "sectionKey": "sectionValue", + }, + }, + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + c := &Config{} + assert.Equal(t, tc.expected, c.fromIniTemplateFunc(tc.text)) + }) + } +} + +func TestKeysFromPath(t *testing.T) { + for _, tc := range []struct { + name string + path any + expectedLastKey string + expectedKeys []string + expectedErr error + }{ + { + name: "string_key", + path: "key", + expectedKeys: []string{}, + expectedLastKey: "key", + }, + { + name: "string_period_separated_keys", + path: "key1.key2", + expectedKeys: []string{"key1"}, + expectedLastKey: "key2", + }, + { + name: "string_period_separated_nested_keys", + path: "key1.key2.key3", + expectedKeys: []string{"key1", "key2"}, + expectedLastKey: "key3", + }, + { + name: "string_empty", + path: "", + expectedErr: errEmptyPath, + }, + { + name: "string_period_separated_empty_key", + path: "key1..key3", + expectedErr: emptyPathElementError{ + index: 1, + }, + }, + { + name: "string_slice_one_key", + path: []string{"key1"}, + expectedKeys: []string{}, + expectedLastKey: "key1", + }, + { + name: "string_slice_two_keys", + path: []string{"key1", "key2"}, + expectedKeys: []string{"key1"}, + expectedLastKey: "key2", + }, + { + name: "string_slice_multiple_keys", + path: []string{"key1", "key2", "key3"}, + expectedKeys: []string{"key1", "key2"}, + expectedLastKey: "key3", + }, + { + name: "string_slice_empty", + path: []string{}, + expectedErr: errEmptyPath, + }, + { + name: "string_slice_empty_key", + path: []string{""}, + expectedErr: emptyPathElementError{ + index: 0, + }, + }, + { + name: "string_slice_empty_key_second", + path: []string{"key", ""}, + expectedErr: emptyPathElementError{ + index: 1, + }, + }, + { + name: "any_slice_nil", + expectedErr: errEmptyPath, + }, + { + name: "any_slice_empty", + path: []any{}, + expectedErr: errEmptyPath, + }, + { + name: "any_slice_one_key", + path: []any{"key"}, + expectedKeys: []string{}, + expectedLastKey: "key", + }, + { + name: "any_slice_two_keys", + path: []any{"key1", "key2"}, + expectedKeys: []string{"key1"}, + expectedLastKey: "key2", + }, + { + name: "any_slice_invalid_key", + path: []any{0}, + expectedErr: invalidPathElementTypeError{ + element: 0, + }, + }, + { + name: "any_slice_empty_key", + path: []any{""}, + expectedErr: emptyPathElementError{ + index: 0, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualKeys, actualLastKey, err := keysFromPath(tc.path) + if tc.expectedErr != nil { + assert.Error(t, tc.expectedErr, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedKeys, actualKeys) + assert.Equal(t, tc.expectedLastKey, actualLastKey) + } + }) + } +} + +func TestNestedMapAtPath(t *testing.T) { + for _, tc := range []struct { + name string + m map[string]any + path any + expectedMap map[string]any + expectedLastKey string + expectedErr error + }{ + { + name: "simple", + m: map[string]any{ + "key": "value", + }, + path: "key", + expectedMap: map[string]any{ + "key": "value", + }, + expectedLastKey: "key", + }, + { + name: "nested_map", + m: map[string]any{ + "key1": map[string]any{ + "key2": "value", + }, + }, + path: "key1.key2", + expectedMap: map[string]any{ + "key2": "value", + }, + expectedLastKey: "key2", + }, + { + name: "not_a_map", + m: map[string]any{ + "key1": "value", + }, + path: "key1.key2", + }, + { + name: "nested_not_a_map", + m: map[string]any{ + "key1": map[string]any{ + "key2": "value", + }, + }, + path: "key1.key2.key3", + }, + { + name: "empty_path", + expectedErr: errEmptyPath, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualMap, actualLastKey, err := nestedMapAtPath(tc.m, tc.path) + if tc.expectedErr != nil { + assert.Equal(t, tc.expectedErr, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedMap, actualMap) + assert.Equal(t, tc.expectedLastKey, actualLastKey) + } + }) + } +} + +func TestToIniTemplateFunc(t *testing.T) { + for i, tc := range []struct { + data map[string]any + expected string + }{ + { + data: map[string]any{ + "bool": true, + "float": 1.0, + "int": 1, + "quotedString": "\"", + "string": "string", + }, + expected: chezmoitest.JoinLines( + `bool = true`, + `float = 1.000000`, + `int = 1`, + `quotedString = "\""`, + `string = string`, + ), + }, + { + data: map[string]any{ + "bool": "true", + "float": "1.0", + "int": "1", + "string": "string string", //nolint:dupword + }, + expected: chezmoitest.JoinLines( + `bool = "true"`, + `float = "1.0"`, + `int = "1"`, + `string = "string string"`, + ), + }, + { + data: map[string]any{ + "key": "value", + "section": map[string]any{ + "subKey": "subValue", + }, + }, + expected: chezmoitest.JoinLines( + `key = value`, + ``, + `[section]`, + `subKey = subValue`, + ), + }, + { + data: map[string]any{ + "section": map[string]any{ + "subsection": map[string]any{ + "subSubKey": "subSubValue", + }, + }, + }, + expected: chezmoitest.JoinLines( + ``, + `[section]`, + ``, + `[section.subsection]`, + `subSubKey = subSubValue`, + ), + }, + { + data: map[string]any{ + "key": "value", + "section": map[string]any{ + "subKey": "subValue", + "subsection": map[string]any{ + "subSubKey": "subSubValue", + }, + }, + }, + expected: chezmoitest.JoinLines( + `key = value`, + ``, + `[section]`, + `subKey = subValue`, + ``, + `[section.subsection]`, + `subSubKey = subSubValue`, + ), + }, + { + data: map[string]any{ + "section1": map[string]any{ + "subKey1": "subValue1", + "subsection1a": map[string]any{ + "subSubKey1a": "subSubValue1a", + }, + "subsection1b": map[string]any{ + "subSubKey1b": "subSubValue1b", + }, + }, + "section2": map[string]any{ + "subKey2": "subValue2", + "subsection2a": map[string]any{ + "subSubKey2a": "subSubValue2a", + }, + "subsection2b": map[string]any{ + "subSubKey2b": "subSubValue2b", + }, + }, + }, + expected: chezmoitest.JoinLines( + ``, + `[section1]`, + `subKey1 = subValue1`, + ``, + `[section1.subsection1a]`, + `subSubKey1a = subSubValue1a`, + ``, + `[section1.subsection1b]`, + `subSubKey1b = subSubValue1b`, + ``, + `[section2]`, + `subKey2 = subValue2`, + ``, + `[section2.subsection2a]`, + `subSubKey2a = subSubValue2a`, + ``, + `[section2.subsection2b]`, + `subSubKey2b = subSubValue2b`, + ), + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + c := &Config{} + actual := c.toIniTemplateFunc(tc.data) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func TestNeedsQuote(t *testing.T) { + for i, tc := range []struct { + s string + expected bool + }{ + { + s: "", + expected: true, + }, + { + s: "\\", + expected: true, + }, + { + s: "\a", + expected: true, + }, + { + s: "abc", + expected: false, + }, + { + s: "true", + expected: true, + }, + { + s: "1", + expected: true, + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + assert.Equal(t, tc.expected, needsQuote(tc.s)) + }) + } +} + +func TestQuoteListTemplateFunc(t *testing.T) { + c, err := newConfig() + assert.NoError(t, err) + actual := c.quoteListTemplateFunc([]any{ + []byte{65}, + "b", + errors.New("error"), + 1, + true, + }) + assert.Equal(t, []string{ + `"A"`, + `"b"`, + `"error"`, + `"1"`, + `"true"`, + }, actual) +} diff --git a/internal/cmd/testdata/scripts/add.txt b/internal/cmd/testdata/scripts/add.txtar similarity index 55% rename from internal/cmd/testdata/scripts/add.txt rename to internal/cmd/testdata/scripts/add.txtar index 6685a13d5cd..6b8699b2f4a 100644 --- a/internal/cmd/testdata/scripts/add.txt +++ b/internal/cmd/testdata/scripts/add.txtar @@ -2,58 +2,54 @@ mkhomedir mksourcedir golden # test chezmoi add --create -chezmoi add --create $HOME${/}.create +exec chezmoi add --create $HOME${/}.create cmp $CHEZMOISOURCEDIR/create_dot_create golden/create_dot_create # test that adding a directory creates a .keep file -chezmoi add --recursive=false $HOME${/}.dir +exec chezmoi add --recursive=false $HOME${/}.dir exists $CHEZMOISOURCEDIR/dot_dir/.keep # test adding a file in a directory -chezmoi add $HOME${/}.dir/file +exec 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 +exec chezmoi add --exact $HOME${/}.dir/subdir +cmp $CHEZMOISOURCEDIR/dot_dir/exact_subdir/file golden/dot_dir/exact_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 empty file +exec chezmoi add $HOME${/}.empty +exists $CHEZMOISOURCEDIR/empty_dot_empty # test adding an executable file -chezmoi add $HOME${/}.executable -[!windows] cmp $CHEZMOISOURCEDIR/executable_dot_executable golden/executable_dot_executable +exec chezmoi add $HOME${/}.executable +[unix] cmp $CHEZMOISOURCEDIR/executable_dot_executable golden/executable_dot_executable [windows] cmp $CHEZMOISOURCEDIR/dot_executable golden/executable_dot_executable # test adding a private file -chezmoi add $HOME${/}.private -[!windows] cmp $CHEZMOISOURCEDIR/private_dot_private $HOME/.private +exec chezmoi add $HOME${/}.private +[unix] cmp $CHEZMOISOURCEDIR/private_dot_private $HOME/.private [windows] cmp $CHEZMOISOURCEDIR/dot_private $HOME/.private # test adding a symlink -chezmoi add $HOME${/}.symlink +exec 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 +exec 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 +exec chezmoi add --follow $HOME${/}.symlink3 cmp $CHEZMOISOURCEDIR/dot_symlink3 golden/dot_file chhome home2/user # test that chezmoi add only creates .keep files in empty directories mkdir $HOME/.dir/empty_subdir -chezmoi add $HOME${/}.dir +exec chezmoi add $HOME${/}.dir ! exists $CHEZMOISOURCEDIR/dot_dir/.keep exists $CHEZMOISOURCEDIR/dot_dir/empty_subdir/.keep ! exists $CHEZMOISOURCEDIR/dot_dir/non_empty_subdir/.keep @@ -61,15 +57,33 @@ exists $CHEZMOISOURCEDIR/dot_dir/empty_subdir/.keep chhome home3/user # test that chezmoi add respects .chezmoiignore -chezmoi add $HOME${/}.dir +exec chezmoi add $HOME${/}.dir exists $CHEZMOISOURCEDIR/dot_dir/file +stderr 'warning: ignoring .dir/ignore' ! exists $CHEZMOISOURCEDIR/dot_dir/ignore +chhome home4/user + +# test that chezmoi add does not overwrite an already-added file +exec chezmoi add $HOME/.file +cmp $CHEZMOISOURCEDIR/dot_file golden/dot_file +edit $HOME/.file +cmp $CHEZMOISOURCEDIR/dot_file golden/dot_file + +# test that chezmoi add --force does overwrite an already-added file +exec chezmoi add --force $HOME/.file +cmp $CHEZMOISOURCEDIR/dot_file golden/edited_dot_file + +-- golden/edited_dot_file -- +# contents of .file +# edited -- home2/user/.dir/non_empty_subdir/file -- # contents of .dir/non_empty_subdir/file --- home3/user/.local/share/chezmoi/.chezmoiignore -- -**/ignore -- home3/user/.dir/file -- # contents of .dir/file -- home3/user/.dir/ignore -- # contents of .dir/ignore +-- home3/user/.local/share/chezmoi/.chezmoiignore -- +**/ignore +-- home4/user/.file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/addattributes.txt b/internal/cmd/testdata/scripts/addattributes.txtar similarity index 90% rename from internal/cmd/testdata/scripts/addattributes.txt rename to internal/cmd/testdata/scripts/addattributes.txtar index 9bde4d162be..371006251f1 100644 --- a/internal/cmd/testdata/scripts/addattributes.txt +++ b/internal/cmd/testdata/scripts/addattributes.txtar @@ -2,7 +2,7 @@ mkhomedir # test that chezmoi add warns if adding a file would remove the template attribute stdin golden/yes -chezmoi add --no-tty $HOME${/}.file +exec chezmoi add --no-tty $HOME${/}.file stdout 'adding \.file would remove template attribute, continue' cmp $CHEZMOISOURCEDIR/dot_file golden/.file ! exists $CHEZMOISOURCEDIR/dot_file.tmpl diff --git a/internal/cmd/testdata/scripts/addautotemplate.txt b/internal/cmd/testdata/scripts/addautotemplate.txtar similarity index 54% rename from internal/cmd/testdata/scripts/addautotemplate.txt rename to internal/cmd/testdata/scripts/addautotemplate.txtar index 4ff6f31a856..24095e0a67c 100644 --- a/internal/cmd/testdata/scripts/addautotemplate.txt +++ b/internal/cmd/testdata/scripts/addautotemplate.txtar @@ -1,26 +1,34 @@ # test that chezmoi add --autotemplate on a file with a replacement creates a template in the source directory -chezmoi add --autotemplate $HOME${/}.template +exec chezmoi add --autotemplate $HOME${/}.template cmp $CHEZMOISOURCEDIR/dot_template.tmpl golden/dot_template.tmpl # test that chezmoi add --autotemplate on a symlink with a replacement creates a template in the source directory symlink $HOME/.symlink -> .target-value -chezmoi add --autotemplate $HOME${/}.symlink +exec chezmoi add --autotemplate $HOME${/}.symlink cmp $CHEZMOISOURCEDIR/symlink_dot_symlink.tmpl golden/symlink_dot_symlink.tmpl # test that chezmoi add --autotemplate does not create a template if no replacements occurred -chezmoi add --autotemplate $HOME${/}.file -cmp $CHEZMOISOURCEDIR/dot_file golden/dot_file +exec chezmoi add --autotemplate $HOME${/}.notatemplate +cmp $CHEZMOISOURCEDIR/dot_notatemplate golden/dot_notatemplate --- golden/dot_file -- -# contents of .file +# test that chezmoi add --autotemplate escapes brackets +exec chezmoi add --autotemplate $HOME${/}.vimrc +cmp $CHEZMOISOURCEDIR/dot_vimrc.tmpl golden/dot_vimrc.tmpl + +-- golden/dot_notatemplate -- +# contents of .notatemplate -- golden/dot_template.tmpl -- key = {{ .variable }} +-- golden/dot_vimrc.tmpl -- +set foldmarker={{ "{{" }},{{ "}}" }} -- golden/symlink_dot_symlink.tmpl -- .target-{{ .variable }} -- home/user/.config/chezmoi/chezmoi.toml -- [data] variable = "value" --- home/user/.file -- -# contents of .file +-- home/user/.notatemplate -- +# contents of .notatemplate -- home/user/.template -- key = value +-- home/user/.vimrc -- +set foldmarker={{,}} diff --git a/internal/cmd/testdata/scripts/addencrypted.txt b/internal/cmd/testdata/scripts/addencrypted.txtar similarity index 58% rename from internal/cmd/testdata/scripts/addencrypted.txt rename to internal/cmd/testdata/scripts/addencrypted.txtar index 17fc5860fdd..630e6531e4f 100644 --- a/internal/cmd/testdata/scripts/addencrypted.txt +++ b/internal/cmd/testdata/scripts/addencrypted.txtar @@ -1,3 +1,4 @@ +[windows] skip 'skipping gpg tests on Windows' [!exec:gpg] skip 'gpg not found in $PATH' mkgpgconfig @@ -5,19 +6,26 @@ mkgpgconfig cp golden/.encrypted $HOME/.encrypted # test that chezmoi add adds a file unencrypted -chezmoi add $HOME${/}.encrypted +exec chezmoi add $HOME${/}.encrypted cmp $CHEZMOISOURCEDIR/dot_encrypted golden/.encrypted # test that chezmoi add --encrypt encrypts the file in the source state -chezmoi add --encrypt $HOME${/}.encrypted +exec chezmoi add --encrypt $HOME${/}.encrypted ! exists $CHEZMOISOURCEDIR/dot_encrypted exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc grep '-----BEGIN PGP MESSAGE-----' $CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc # test that chezmoi add without --encrypt replaces the source file -chezmoi add --force $HOME${/}.encrypted +exec chezmoi add --force $HOME${/}.encrypted ! exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc cmp $CHEZMOISOURCEDIR/dot_encrypted golden/.encrypted +# test that chezmoi add always encrypts when add.encrypt is true +appendline $CHEZMOICONFIGDIR/chezmoi.toml '[add]' +appendline $CHEZMOICONFIGDIR/chezmoi.toml ' encrypt = true' +cp golden/.encrypted $HOME/.encrypted2 +exec chezmoi add $HOME/.encrypted2 +exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted2.asc + -- golden/.encrypted -- # contents of .encrypted diff --git a/internal/cmd/testdata/scripts/addsecrets.txtar b/internal/cmd/testdata/scripts/addsecrets.txtar new file mode 100644 index 00000000000..d9796f00722 --- /dev/null +++ b/internal/cmd/testdata/scripts/addsecrets.txtar @@ -0,0 +1,29 @@ +[windows] skip 'test requires path separator to be forward slash' + +# test that chezmoi add --secrets=ignore does not generate a warning when adding a file with a secret +exec chezmoi add --secrets=ignore $HOME${/}.secret +! stderr . +exists $CHEZMOISOURCEDIR/dot_secret + +# test that chezmoi add --secrets=warning generates a warning when adding a file with a secret but still adds the file +rm $CHEZMOISOURCEDIR/dot_secret +exec chezmoi add --secrets=warning $HOME${/}.secret +cmpenv stderr golden/stderr +exists $CHEZMOISOURCEDIR/dot_secret + +# test that chezmoi add --secrets=error generates an error when adding a file with a secret and does not add the file +rm $CHEZMOISOURCEDIR/dot_secret +! exec chezmoi add --secrets=error $HOME${/}.secret +cmpenv stderr golden/stderr +! exists $CHEZMOISOURCEDIR/dot_secret + +# test that chezmoi add --force --secrets=error generates an error when adding a file with a secret but still adds the file +rm $CHEZMOISOURCEDIR/dot_secret +exec chezmoi add --force --secrets=error $HOME${/}.secret +cmpenv stderr golden/stderr +exists $CHEZMOISOURCEDIR/dot_secret + +-- golden/stderr -- +chezmoi: $WORK/home/user/.secret:1: Identified a pattern that may indicate AWS credentials, risking unauthorized cloud resource access and data breaches on AWS platforms. +-- home/user/.secret -- +AWS_ACCESS_KEY_ID=AKIA0000000000000000 diff --git a/internal/cmd/testdata/scripts/age.txtar b/internal/cmd/testdata/scripts/age.txtar new file mode 100644 index 00000000000..90ae998205e --- /dev/null +++ b/internal/cmd/testdata/scripts/age.txtar @@ -0,0 +1,17 @@ +# test that chezmoi age encrypt encrypts a file with a passphrase +stdin $HOME/passphrases +exec chezmoi age encrypt --output $HOME${/}secret.txt.age --passphrase --no-tty $HOME${/}secret.txt +grep '-----BEGIN AGE ENCRYPTED FILE----' $HOME/secret.txt.age + +# test that chezmoi age decrypt decrypts a file with a passphrase +stdin $HOME/passphrase +exec chezmoi age decrypt --output $HOME${/}secret.txt.decrypted --passphrase --no-tty $HOME${/}secret.txt.age +cmp $HOME/secret.txt.decrypted $HOME/secret.txt + +-- home/user/passphrase -- +passphrase +-- home/user/passphrases -- +passphrase +passphrase +-- home/user/secret.txt -- +secret diff --git a/internal/cmd/testdata/scripts/ageencryption.txt b/internal/cmd/testdata/scripts/ageencryption.txtar similarity index 69% rename from internal/cmd/testdata/scripts/ageencryption.txt rename to internal/cmd/testdata/scripts/ageencryption.txtar index 0681d952316..a9d9b9e1946 100644 --- a/internal/cmd/testdata/scripts/ageencryption.txt +++ b/internal/cmd/testdata/scripts/ageencryption.txtar @@ -5,55 +5,55 @@ mkageconfig # test that chezmoi add --encrypt encrypts cp golden/.encrypted $HOME -chezmoi add --encrypt $HOME${/}.encrypted +exec chezmoi add --encrypt $HOME${/}.encrypted exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted.age grep '-----BEGIN AGE ENCRYPTED FILE-----' $CHEZMOISOURCEDIR/encrypted_dot_encrypted.age cp $CHEZMOISOURCEDIR/encrypted_dot_encrypted.age golden # test that chezmoi apply decrypts rm $HOME/.encrypted -chezmoi apply --force +exec chezmoi apply --force cmp golden/.encrypted $HOME/.encrypted # test that chezmoi apply --exclude=encrypted does not apply encrypted files rm $HOME/.encrypted -chezmoi apply --exclude=encrypted --force +exec chezmoi apply --exclude=encrypted --force ! exists $HOME/.encrypted -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.encrypted golden/.encrypted # test that chezmoi detects age encryption if age is configured but encryption = "age" is not set removeline $CHEZMOICONFIGDIR/chezmoi.toml 'encryption = "age"' -chezmoi cat $HOME${/}.encrypted +exec chezmoi cat $HOME${/}.encrypted cmp stdout golden/.encrypted # test that chezmoi decrypt decrypts stdin stdin $CHEZMOISOURCEDIR${/}encrypted_dot_encrypted.age -chezmoi decrypt +exec chezmoi decrypt cmp stdout golden/.encrypted # test that chezmoi decrypt decrypts a file -chezmoi decrypt $CHEZMOISOURCEDIR${/}encrypted_dot_encrypted.age +exec chezmoi decrypt $CHEZMOISOURCEDIR${/}encrypted_dot_encrypted.age cmp stdout golden/.encrypted # test chezmoi encrypt/chezmoi decrypt round trip -chezmoi encrypt golden/.encrypted +exec chezmoi encrypt golden/.encrypted stdout '-----BEGIN AGE ENCRYPTED FILE-----' stdin stdout -chezmoi decrypt +exec chezmoi decrypt cmp stdout golden/.encrypted # test that chezmoi --use-builtin-age=true decrypt decrypts a file encrypted by age -chezmoi --use-builtin-age=true decrypt $CHEZMOISOURCEDIR${/}encrypted_dot_encrypted.age +exec chezmoi --use-builtin-age=true decrypt $CHEZMOISOURCEDIR${/}encrypted_dot_encrypted.age cmp stdout golden/.encrypted # test that chezmoi --use-builtin-age=true encrypts a file than age then decrypts -chezmoi --use-builtin-age=true --output=$WORK${/}encrypted.age encrypt golden/.encrypted -chezmoi --use-builtin-age=false decrypt $WORK${/}encrypted.age +exec chezmoi --use-builtin-age=true --output=$WORK${/}encrypted.age encrypt golden/.encrypted +exec chezmoi --use-builtin-age=false decrypt $WORK${/}encrypted.age cmp stdout golden/.encrypted # test that chezmoi edit --apply transparently decrypts and re-encrypts -chezmoi edit --apply --force $HOME${/}.encrypted +exec chezmoi edit --apply --force $HOME${/}.encrypted grep '# edited' $HOME/.encrypted -- golden/.encrypted -- diff --git a/internal/cmd/testdata/scripts/ageencryptionsymmetric.txt b/internal/cmd/testdata/scripts/ageencryptionsymmetric.txtar similarity index 85% rename from internal/cmd/testdata/scripts/ageencryptionsymmetric.txt rename to internal/cmd/testdata/scripts/ageencryptionsymmetric.txtar index 9e7b334a6cb..6e4d0f848bc 100644 --- a/internal/cmd/testdata/scripts/ageencryptionsymmetric.txt +++ b/internal/cmd/testdata/scripts/ageencryptionsymmetric.txtar @@ -5,13 +5,13 @@ mkageconfig -symmetric # test that chezmoi add --encrypt encrypts cp golden/.encrypted $HOME -chezmoi add --encrypt $HOME${/}.encrypted +exec chezmoi add --encrypt $HOME${/}.encrypted exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted.age grep '-----BEGIN AGE ENCRYPTED FILE-----' $CHEZMOISOURCEDIR/encrypted_dot_encrypted.age # test that chezmoi apply decrypts rm $HOME/.encrypted -chezmoi apply --force +exec chezmoi apply --force cmp golden/.encrypted $HOME/.encrypted -- golden/.encrypted -- diff --git a/internal/cmd/testdata/scripts/apply.txt b/internal/cmd/testdata/scripts/apply.txtar similarity index 79% rename from internal/cmd/testdata/scripts/apply.txt rename to internal/cmd/testdata/scripts/apply.txtar index 490176b67f7..cb00e269acc 100644 --- a/internal/cmd/testdata/scripts/apply.txt +++ b/internal/cmd/testdata/scripts/apply.txtar @@ -2,7 +2,7 @@ mkhomedir golden mksourcedir # test that chezmoi apply --dry-run does not create any files -chezmoi apply --dry-run --force +exec chezmoi apply --dry-run --force ! exists $HOME/.create ! exists $HOME/.dir ! exists $HOME/.dir/file @@ -16,7 +16,7 @@ chezmoi apply --dry-run --force ! exists $HOME/.template # test that chezmoi apply file creates a single file only -chezmoi apply --force $HOME${/}.file +exec chezmoi apply --force $HOME${/}.file ! exists $HOME/.create ! exists $HOME/.dir ! exists $HOME/.dir/file @@ -30,21 +30,21 @@ exists $HOME/.file ! exists $HOME/.template # test that chezmoi apply dir --recursive=false creates only the directory -chezmoi apply --force --recursive=false $HOME${/}.dir +exec 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 +exec 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 +exec chezmoi apply --force exists $HOME/.create exists $HOME/.dir exists $HOME/.dir/file @@ -59,13 +59,14 @@ exists $HOME/.template # test apply after edit edit $CHEZMOISOURCEDIR/dot_file -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.file $CHEZMOISOURCEDIR/dot_file # test that chezmoi apply --source-path applies a file based on its source path edit $CHEZMOISOURCEDIR/dot_file -chezmoi apply --force --source-path $CHEZMOISOURCEDIR/dot_file +exec chezmoi apply --force --source-path $CHEZMOISOURCEDIR/dot_file grep -count=2 '# edited' $HOME/.file # test that chezmoi apply --source-path fails when called with a targetDirAbsPath -! chezmoi apply --force --source-path $HOME${/}.file +! exec chezmoi apply --force --source-path $HOME${/}.file +[!windows] stderr ${HOME@R}/\.file:\snot\sin\s${CHEZMOISOURCEDIR@R}$ diff --git a/internal/cmd/testdata/scripts/applychmod_unix.txt b/internal/cmd/testdata/scripts/applychmod_unix.txtar similarity index 78% rename from internal/cmd/testdata/scripts/applychmod_unix.txt rename to internal/cmd/testdata/scripts/applychmod_unix.txtar index 20d62f45f3a..e1587a6effd 100644 --- a/internal/cmd/testdata/scripts/applychmod_unix.txt +++ b/internal/cmd/testdata/scripts/applychmod_unix.txtar @@ -6,15 +6,15 @@ mksourcedir # test change file mode chmod 777 $HOME/.file -chezmoi apply --force +exec chezmoi apply --force cmpmod 666 $HOME/.file # test change executable file mode chmod 666 $HOME/.executable -chezmoi apply --force +exec chezmoi apply --force cmpmod 777 $HOME/.executable # test change directory mode chmod 700 $HOME/.dir -chezmoi apply --force +exec chezmoi apply --force cmpmod 777 $HOME/.dir diff --git a/internal/cmd/testdata/scripts/applyexact.txt b/internal/cmd/testdata/scripts/applyexact.txtar similarity index 89% rename from internal/cmd/testdata/scripts/applyexact.txt rename to internal/cmd/testdata/scripts/applyexact.txtar index ea1c71ee574..e18835ccbc7 100644 --- a/internal/cmd/testdata/scripts/applyexact.txt +++ b/internal/cmd/testdata/scripts/applyexact.txtar @@ -1,11 +1,11 @@ # test that chezmoi apply --dry-run does not remove entries from exact directories -chezmoi apply --dry-run --force +exec 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 +exec chezmoi apply --force exists $HOME/.dir/file1 ! exists $HOME/.dir/file2 ! exists $HOME/.dir/subdir/file diff --git a/internal/cmd/testdata/scripts/applyremove.txt b/internal/cmd/testdata/scripts/applyremove.txtar similarity index 76% rename from internal/cmd/testdata/scripts/applyremove.txt rename to internal/cmd/testdata/scripts/applyremove.txtar index 4000901baf2..2cf1d0bcd67 100644 --- a/internal/cmd/testdata/scripts/applyremove.txt +++ b/internal/cmd/testdata/scripts/applyremove.txtar @@ -1,17 +1,17 @@ # test that chezmoi apply --dry-run does not remove entries -chezmoi apply --dry-run --force +exec chezmoi apply --dry-run --force exists $HOME/.dir/file exists $HOME/.file1 exists $HOME/.file2 -# test that chezmoi apply file removes only file -chezmoi apply --force $HOME${/}.file1 +# test that chezmoi apply file1 removes only file1 +exec chezmoi apply --force $HOME${/}.file1 exists $HOME/.dir/file ! exists $HOME/.file1 exists $HOME/.file2 # test that chezmoi apply removes all entries -chezmoi apply --force +exec chezmoi apply --force ! exists $HOME/.dir/file ! exists $HOME/.file1 ! exists $HOME/.file2 diff --git a/internal/cmd/testdata/scripts/applyskipencrypted.txt b/internal/cmd/testdata/scripts/applyskipencrypted.txtar similarity index 74% rename from internal/cmd/testdata/scripts/applyskipencrypted.txt rename to internal/cmd/testdata/scripts/applyskipencrypted.txtar index 4e9a14ae92d..18ffca53170 100644 --- a/internal/cmd/testdata/scripts/applyskipencrypted.txt +++ b/internal/cmd/testdata/scripts/applyskipencrypted.txtar @@ -1,3 +1,4 @@ +[windows] skip 'skipping gpg tests on Windows' [!exec:gpg] skip 'gpg not found in $PATH' mkhomedir @@ -5,16 +6,16 @@ mkgpgconfig # test that chezmoi apply --exclude=encrypted does not apply encrypted files cp golden/.encrypted $HOME -chezmoi add --encrypt $HOME${/}.encrypted +exec chezmoi add --encrypt $HOME${/}.encrypted rm $HOME/.encrypted cp $CHEZMOICONFIGDIR/chezmoi.toml golden/chezmoi.toml rm $CHEZMOICONFIGDIR/chezmoi.toml -chezmoi apply --force --exclude=encrypted +exec chezmoi apply --force --exclude=encrypted ! exists $HOME/.encrypted # test that chezmoi apply applies the encrypted file cp golden/chezmoi.toml $CHEZMOICONFIGDIR/chezmoi.toml -chezmoi apply --force +exec chezmoi apply --force cmp golden/.encrypted $HOME/.encrypted -- golden/.encrypted -- diff --git a/internal/cmd/testdata/scripts/applysourcepath.txt b/internal/cmd/testdata/scripts/applysourcepath.txtar similarity index 54% rename from internal/cmd/testdata/scripts/applysourcepath.txt rename to internal/cmd/testdata/scripts/applysourcepath.txtar index 98a1dd8be38..550f07a30d6 100644 --- a/internal/cmd/testdata/scripts/applysourcepath.txt +++ b/internal/cmd/testdata/scripts/applysourcepath.txtar @@ -1,15 +1,15 @@ mksourcedir # test that chezmoi apply --source-path only applies the target -chezmoi apply --source-path $CHEZMOISOURCEDIR/dot_file +exec chezmoi apply --source-path $CHEZMOISOURCEDIR/dot_file ! exists $HOME/.empty exists $HOME/.file -chezmoi apply --source-path $CHEZMOISOURCEDIR/empty_dot_empty +exec chezmoi apply --source-path $CHEZMOISOURCEDIR/empty_dot_empty exists $HOME/.empty # test that chezmoi apply --source-path ignores other modified files edit $HOME/.file -chezmoi status +exec chezmoi status stdout 'MM \.file' -chezmoi apply --source-path $CHEZMOISOURCEDIR/executable_dot_executable +exec chezmoi apply --source-path $CHEZMOISOURCEDIR/executable_dot_executable exists $HOME/.executable diff --git a/internal/cmd/testdata/scripts/applystate.txt b/internal/cmd/testdata/scripts/applystate.txtar similarity index 86% rename from internal/cmd/testdata/scripts/applystate.txt rename to internal/cmd/testdata/scripts/applystate.txtar index 7a5b39f7878..b756e79005f 100644 --- a/internal/cmd/testdata/scripts/applystate.txt +++ b/internal/cmd/testdata/scripts/applystate.txtar @@ -3,9 +3,9 @@ mksourcedir # test that chezmoi apply does not modify the state if nothing needs to be done -chezmoi apply --force +exec chezmoi apply --force exec sha256sum $CHEZMOICONFIGDIR/chezmoistate.boltdb cp stdout chezmoistate.boltdb-sha256-pre-apply -chezmoi apply --force +exec chezmoi apply --force exec sha256sum $CHEZMOICONFIGDIR/chezmoistate.boltdb cmp stdout chezmoistate.boltdb-sha256-pre-apply diff --git a/internal/cmd/testdata/scripts/applytype.txt b/internal/cmd/testdata/scripts/applytype.txt deleted file mode 100644 index f9ac8fa5027..00000000000 --- a/internal/cmd/testdata/scripts/applytype.txt +++ /dev/null @@ -1,22 +0,0 @@ -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/internal/cmd/testdata/scripts/applytype.txtar b/internal/cmd/testdata/scripts/applytype.txtar new file mode 100644 index 00000000000..5dbc8388c4b --- /dev/null +++ b/internal/cmd/testdata/scripts/applytype.txtar @@ -0,0 +1,29 @@ +mkhomedir golden +mkhomedir +mksourcedir + +# test that chezmoi apply replaces a directory with a file +rm $HOME/.file +mkdir $HOME/.file +exec chezmoi apply --force +cmp $HOME/.file golden/.file + +# test that chezmoi apply replaces a file with a directory +rm $HOME/.dir +mkfile $HOME/.dir +exec chezmoi apply --force +cmp $HOME/.dir/file golden/.dir/file +cmp $HOME/.dir/subdir/file golden/.dir/subdir/file + +# test that chezmoi apply replaces a file with a symlink +rm $HOME/.symlink +mkfile $HOME/.symlink +exec chezmoi apply --force +cmp $HOME/.symlink golden/.symlink + +# test that chezmoi apply replaces a symlink with a directory +rm $HOME/.dir/subdir +symlink $HOME/.dir/subdir -> .file +exec chezmoi apply --force +cmp $HOME/.dir/file golden/.dir/file +cmp $HOME/.dir/subdir/file golden/.dir/subdir/file diff --git a/internal/cmd/testdata/scripts/applyverbose.txt b/internal/cmd/testdata/scripts/applyverbose.txtar similarity index 65% rename from internal/cmd/testdata/scripts/applyverbose.txt rename to internal/cmd/testdata/scripts/applyverbose.txtar index 641af28b331..09216ad5382 100644 --- a/internal/cmd/testdata/scripts/applyverbose.txt +++ b/internal/cmd/testdata/scripts/applyverbose.txtar @@ -1,15 +1,24 @@ # test that chezmoi apply --dry-run --verbose does not show scripts when scripts are excluded from diffs -chezmoi apply --dry-run --verbose -[!windows] cmp stdout golden/apply -[windows] cmp stdout golden/apply-windows +exec chezmoi apply --dry-run --verbose +[unix] [umask:002] cmp stdout golden/apply-umask-002.diff +[unix] [umask:022] cmp stdout golden/apply-umask-022.diff +[windows] cmp stdout golden/apply-windows.diff chhome home2/user # test that chezmoi apply --dry-run --force --verbose does not show removes when removes are excluded from diffs -chezmoi apply --dry-run --force --verbose +exec chezmoi apply --dry-run --force --verbose ! stdout . --- golden/apply -- +-- golden/apply-umask-002.diff -- +diff --git a/.file b/.file +new file mode 100664 +index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 +--- /dev/null ++++ b/.file +@@ -0,0 +1 @@ ++# contents of .file +-- golden/apply-umask-022.diff -- diff --git a/.file b/.file new file mode 100644 index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 @@ -17,7 +26,7 @@ index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104 +++ b/.file @@ -0,0 +1 @@ +# contents of .file --- golden/apply-windows -- +-- golden/apply-windows.diff -- diff --git a/.file b/.file new file mode 100666 index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 diff --git a/internal/cmd/testdata/scripts/archivetar.txt b/internal/cmd/testdata/scripts/archivetar.txtar similarity index 51% rename from internal/cmd/testdata/scripts/archivetar.txt rename to internal/cmd/testdata/scripts/archivetar.txtar index c39f27e6dff..f68bca3b063 100644 --- a/internal/cmd/testdata/scripts/archivetar.txt +++ b/internal/cmd/testdata/scripts/archivetar.txtar @@ -2,16 +2,14 @@ mksourcedir [windows] unix2dos golden/archive-tar -chezmoi archive --output=archive.tar +exec chezmoi archive --output=archive.tar exec tar -tf archive.tar -[!(illumos||openbsd)] cmp stdout golden/archive-tar -[illumos] cmp stdout golden/archive-tar-illumos +[!openbsd] cmp stdout golden/archive-tar [openbsd] cmp stdout golden/archive-tar-openbsd -chezmoi archive --gzip --output=archive.tar.gz +exec chezmoi archive --gzip --output=archive.tar.gz exec tar -tzf archive.tar.gz -[!(illumos||openbsd)] cmp stdout golden/archive-tar -[illumos] cmp stdout golden/archive-tar-illumos +[!openbsd] cmp stdout golden/archive-tar [openbsd] cmp stdout golden/archive-tar-openbsd -- golden/archive-tar -- @@ -27,19 +25,6 @@ exec tar -tzf archive.tar.gz .readonly .symlink .template --- golden/archive-tar-illumos -- -.create -.dir/ -.dir/file -.dir/subdir/ -.dir/subdir/file -.empty -.executable -.file -.private -.readonly -.symlink symbolic link to .dir/subdir/file -.template -- golden/archive-tar-openbsd -- .create .dir diff --git a/internal/cmd/testdata/scripts/archivezip.txt b/internal/cmd/testdata/scripts/archivezip.txtar similarity index 87% rename from internal/cmd/testdata/scripts/archivezip.txt rename to internal/cmd/testdata/scripts/archivezip.txtar index d1bbd84f8ad..1d85ddaae61 100644 --- a/internal/cmd/testdata/scripts/archivezip.txt +++ b/internal/cmd/testdata/scripts/archivezip.txtar @@ -1,8 +1,9 @@ +[windows] skip 'windows line endings confuse cmp and diff' [!exec:unzip] skip 'unzip not found in $PATH' mksourcedir -chezmoi archive --format=zip --output=archive.zip +exec chezmoi archive --format=zip --output=archive.zip exec unzip -t archive.zip [!freebsd] cmp stdout golden/archive # FIXME whitespace output of unzip is different on FreeBSD diff --git a/internal/cmd/testdata/scripts/autocommit.txt b/internal/cmd/testdata/scripts/autocommit.txt deleted file mode 100644 index 7fa2535377b..00000000000 --- a/internal/cmd/testdata/scripts/autocommit.txt +++ /dev/null @@ -1,26 +0,0 @@ -[!exec:git] skip '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/internal/cmd/testdata/scripts/autocommit.txtar b/internal/cmd/testdata/scripts/autocommit.txtar new file mode 100644 index 00000000000..1ed0d473760 --- /dev/null +++ b/internal/cmd/testdata/scripts/autocommit.txtar @@ -0,0 +1,72 @@ +[!exec:git] skip 'git not found in $PATH' + +mkgitconfig +mkhomedir golden +mkhomedir + +exec chezmoi init + +# test that chezmoi add creates and pushes a commit +exec chezmoi add $HOME${/}.file +exec git -C $CHEZMOISOURCEDIR show HEAD +stdout 'Add \.file' + +# test that chezmoi edit creates and pushes a commit +exec chezmoi edit $HOME${/}.file +exec git -C $CHEZMOISOURCEDIR show HEAD +stdout 'Update \.file' + +# test that chezmoi chattr creates and pushes a commit +exec chezmoi chattr +executable $HOME${/}.file +exec git -C $CHEZMOISOURCEDIR show HEAD +stdout 'Change attributes of \.file' + +# test that chezmoi add on a directory creates and pushes a commit +exec chezmoi add $HOME${/}.dir +exec git -C $CHEZMOISOURCEDIR show HEAD +stdout 'Add \.dir/file' + +# test that copying a file creates a valid commit message +cp $CHEZMOISOURCEDIR/executable_dot_file $CHEZMOISOURCEDIR/executable_dot_file2 +mv $CHEZMOISOURCEDIR/executable_dot_file $CHEZMOISOURCEDIR/executable_dot_file3 +exec git -C $CHEZMOISOURCEDIR config --local diff.renames copies +exec git -C $CHEZMOISOURCEDIR add . +exec chezmoi edit $HOME${/}.dir${/}file +exec git -C $CHEZMOISOURCEDIR show HEAD +stdout 'Copy \.file to \.file2' + +# test that chezmoi chattr on a file in a directory creates and pushes a commit +exec chezmoi chattr --debug +private $HOME${/}.dir/file +exec git -C $CHEZMOISOURCEDIR show HEAD +stdout 'Change attributes of \.dir' + +# test that chezmoi forget creates and pushes a commit +exec chezmoi forget --force $HOME${/}.file2 +exec git -C $CHEZMOISOURCEDIR show HEAD +stdout 'Remove \.file2' + +# test that chezmoi edit uses a custom commit message template +appendline $CHEZMOICONFIGDIR/chezmoi.toml ' commitMessageTemplate = "{{ .prefix }}my commit message"' +exec chezmoi edit $HOME${/}.dir${/}file +exec git -C $CHEZMOISOURCEDIR show HEAD +stdout 'feat: my commit message' + +# test that only one of git.commitMessageTemplate and git.commitMessageTemplateFile can be set +appendline $CHEZMOICONFIGDIR/chezmoi.toml ' commitMessageTemplateFile = ".COMMIT_MESSAGE.tmpl"' +! exec chezmoi edit $HOME${/}.dir${/}file +stderr 'cannot specify both git.commitMessageTemplate and git.commitMessageTemplateFile' +removeline $CHEZMOICONFIGDIR/chezmoi.toml ' commitMessageTemplate = "{{ .prefix }}my commit message"' + +# test that chezmoi edit uses a custom commit message template file +exec chezmoi edit $HOME${/}.dir${/}file +exec git -C $CHEZMOISOURCEDIR show HEAD +stdout 'feat: my commit message file' +removeline $CHEZMOICONFIGDIR/chezmoi.toml ' commitMessageTemplateFile = ".COMMIT_MESSAGE.tmpl"' + +-- home/user/.config/chezmoi/chezmoi.toml -- +[data] + prefix = "feat: " +[git] + autoCommit = true +-- home/user/.local/share/chezmoi/.COMMIT_MESSAGE.tmpl -- +{{ .prefix }}my commit message file diff --git a/internal/cmd/testdata/scripts/autopush.txt b/internal/cmd/testdata/scripts/autopush.txt deleted file mode 100644 index 59ba04bf2fa..00000000000 --- a/internal/cmd/testdata/scripts/autopush.txt +++ /dev/null @@ -1,28 +0,0 @@ -[!exec:git] skip '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/internal/cmd/testdata/scripts/autopush.txtar b/internal/cmd/testdata/scripts/autopush.txtar new file mode 100644 index 00000000000..6543af0bf4f --- /dev/null +++ b/internal/cmd/testdata/scripts/autopush.txtar @@ -0,0 +1,43 @@ +[!exec:git] skip 'git not found in $PATH' + +mkgitconfig +mkhomedir golden +mkhomedir + +# create a repo +exec git init --bare $WORK/dotfiles.git +exec chezmoi init file://$WORK/dotfiles.git + +# test that chezmoi add creates and pushes a commit +exec chezmoi add $HOME${/}.file +exec git --git-dir=$WORK/dotfiles.git show HEAD +stdout 'Add \.file' + +# test that chezmoi edit creates and pushes a commit +exec chezmoi edit $HOME${/}.file +exec git --git-dir=$WORK/dotfiles.git show HEAD +stdout 'Update \.file' + +# test that chezmoi chattr on a file creates and pushes a commit +exec chezmoi chattr +executable $HOME${/}.file +exec git --git-dir=$WORK/dotfiles.git show HEAD +stdout 'Change attributes of \.file' + +# test that chezmoi add on a directory creates and pushes a commit +exec chezmoi add $HOME${/}.dir +exec git --git-dir=$WORK/dotfiles.git show HEAD +stdout 'Add \.dir/file' + +# test that chezmoi chattr on a file in a directory creates and pushes a commit +exec chezmoi chattr --debug +private $HOME${/}.dir/file +exec git --git-dir=$WORK/dotfiles.git show HEAD +stdout 'Change attributes of \.dir' + +# test that chezmoi forget creates and pushes a commit +exec chezmoi forget --force $HOME${/}.file +exec git --git-dir=$WORK/dotfiles.git show HEAD +stdout 'Remove \.file' + +-- home/user/.config/chezmoi/chezmoi.toml -- +[git] + autoPush = true diff --git a/internal/cmd/testdata/scripts/bitwarden.txt b/internal/cmd/testdata/scripts/bitwarden.txt deleted file mode 100644 index f37759a5e0b..00000000000 --- a/internal/cmd/testdata/scripts/bitwarden.txt +++ /dev/null @@ -1,103 +0,0 @@ -[!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 - -# test bitwardenAttachment template function -chezmoi execute-template '{{ (bitwardenAttachment "filename" "item-id") }}' -stdout hidden-file-value - --- bin/bw -- -#!/bin/sh - -case "$*" in -"get item example.com") - cat < $WORK/env.log +pwd > $WORK/pwd.log +echo shell $* +-- home/user/.dir/.keep -- +-- home/user/.local/share/chezmoi/dot_dir/.keep -- +-- home/user/.local/share/chezmoi/dot_file -- +-- home2/user/.config/chezmoi/chezmoi.toml -- +[cd] + command = "shell" + args = ["arg2"] +-- home3/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home3/user/.local/share/chezmoi/home/.keep -- diff --git a/internal/cmd/testdata/scripts/cd_windows.txt b/internal/cmd/testdata/scripts/cd_windows.txt deleted file mode 100644 index 305db5c528d..00000000000 --- a/internal/cmd/testdata/scripts/cd_windows.txt +++ /dev/null @@ -1,11 +0,0 @@ -[!windows] skip 'Windows only' - -# test chezmoi cd with command with args (Windows variant) -chezmoi cd -! stdout PowerShell -stdout evidence - --- home/user/.config/chezmoi/chezmoi.toml -- -[cd] - command = "powershell" - args = ["-nologo", "-command", "Write-Host 'evidence'"] diff --git a/internal/cmd/testdata/scripts/cd_windows.txtar b/internal/cmd/testdata/scripts/cd_windows.txtar new file mode 100644 index 00000000000..b09fc16fc1b --- /dev/null +++ b/internal/cmd/testdata/scripts/cd_windows.txtar @@ -0,0 +1,21 @@ +[unix] skip 'Windows only' + +# test chezmoi cd with command with args +exec chezmoi cd +! stdout PowerShell +stdout arg1 + +chhome home2/user + +# test chezmoi cd when $SHELL environment variable contains spaces +env SHELL='shell arg2' +exec chezmoi cd +stdout 'arg2' + +-- bin/shell.cmd -- +@echo off +echo %* +-- home/user/.config/chezmoi/chezmoi.toml -- +[cd] + command = "powershell" + args = ["-nologo", "-command", "Write-Host 'arg1'"] diff --git a/internal/cmd/testdata/scripts/chattr.txt b/internal/cmd/testdata/scripts/chattr.txtar similarity index 57% rename from internal/cmd/testdata/scripts/chattr.txt rename to internal/cmd/testdata/scripts/chattr.txtar index 2921868aa0d..78ba3bf0691 100644 --- a/internal/cmd/testdata/scripts/chattr.txt +++ b/internal/cmd/testdata/scripts/chattr.txtar @@ -2,140 +2,166 @@ mksourcedir # test that chezmoi chattr empty sets the empty attribute on a file exists $CHEZMOISOURCEDIR/dot_file -chezmoi chattr empty $HOME${/}.file +exec chezmoi chattr empty $HOME${/}.file ! exists $CHEZMOISOURCEDIR/dot_file exists $CHEZMOISOURCEDIR/empty_dot_file +# test that chezmoi chattr remove sets the remove attribute on a file +exec chezmoi chattr remove $HOME${/}.file +! exists $CHEZMOISOURCEDIR/empty_dot_file +exists $CHEZMOISOURCEDIR/remove_dot_file + +# test that chezmoi chattr noremove removes the remove attribute on a file +exec chezmoi chattr noremove,empty $HOME${/}.file +! exists $CHEZMOISOURCEDIR/remove_dot_file +exists $CHEZMOISOURCEDIR/empty_dot_file + # test that chezmoi attr +p sets the private attribute on a file -chezmoi chattr +p $HOME${/}.file +exec chezmoi chattr +p $HOME${/}.file ! exists $CHEZMOISOURCEDIR/empty_dot_file exists $CHEZMOISOURCEDIR/private_empty_dot_file # test that chezmoi chattr t,-e sets the template attribute and removes the empty attribute on a file -chezmoi chattr t,-e $HOME${/}.file +exec chezmoi chattr t,-e $HOME${/}.file ! exists $CHEZMOISOURCEDIR/private_empty_dot_file exists $CHEZMOISOURCEDIR/private_dot_file.tmpl # test that chezmoi chattr -- -e,-p,r sets the readonly attribute on a file and removes the empty and private attributes -chezmoi chattr -- -e,-p,r $HOME${/}.file +exec chezmoi chattr -- -e,-p,r $HOME${/}.file ! exists $CHEZMOISOURCEDIR/private_dot_file.tmpl exists $CHEZMOISOURCEDIR/readonly_dot_file.tmpl # test that chezmoi chattr -- -r,-t removes the readonly and template attributes on a file -chezmoi chattr -- -r,-t $HOME${/}.file +exec chezmoi chattr -- -r,-t $HOME${/}.file ! exists $CHEZMOISOURCEDIR/readonly_dot_file.tmpl exists $CHEZMOISOURCEDIR/dot_file # test that chezmoi chattr +create changes a file to be a create_ file -chezmoi chattr +create $HOME${/}.file +exec chezmoi chattr +create $HOME${/}.file ! exists $CHEZMOISOURCEDIR/dot_file exists $CHEZMOISOURCEDIR/create_dot_file # test that chezmoi chattr nomodify does not change a create_ file -chezmoi chattr nomodify $HOME${/}.file +exec chezmoi chattr nomodify $HOME${/}.file ! exists $CHEZMOISOURCEDIR/dot_file exists $CHEZMOISOURCEDIR/create_dot_file # test that chezmoi chattr modify,script,symlink changes a create_ file to a symlink_ -chezmoi chattr modify,script,symlink $HOME${/}.file +exec chezmoi chattr modify,script,symlink $HOME${/}.file ! exists $CHEZMOISOURCEDIR/create_dot_file exists $CHEZMOISOURCEDIR/symlink_dot_file # test that chezmoi chattr -- -symlink changes a symlink_ to a regular file -chezmoi chattr -- -symlink $HOME${/}.file +exec chezmoi chattr -- -symlink $HOME${/}.file ! exists $CHEZMOISOURCEDIR/symlink_dot_file exists $CHEZMOISOURCEDIR/dot_file # test that chezmoi chattr nox removes the execute attribute on a file exists $CHEZMOISOURCEDIR/executable_dot_executable -chezmoi chattr nox $HOME${/}.executable +exec chezmoi chattr nox $HOME${/}.executable ! exists $CHEZMOISOURCEDIR/executable_dot_executable exists $CHEZMOISOURCEDIR/dot_executable # test that chezmoi chattr x sets the executable attribute on a file -chezmoi chattr x $HOME${/}.executable +exec chezmoi chattr x $HOME${/}.executable ! exists $CHEZMOISOURCEDIR/dot_executable exists $CHEZMOISOURCEDIR/executable_dot_executable # test that chezmoi chattr +private sets the private attribute on a create file -chezmoi chattr +private $HOME${/}.create +exec chezmoi chattr +private $HOME${/}.create ! exists $CHEZMOISOURCEDIR/create_dot_create exists $CHEZMOISOURCEDIR/create_private_dot_create # test that chezmoi chattr noprivate removes the private attribute on a create file -chezmoi chattr noprivate $HOME${/}.create +exec chezmoi chattr noprivate $HOME${/}.create ! exists $CHEZMOISOURCEDIR/create_private_dot_create exists $CHEZMOISOURCEDIR/create_dot_create # test that chezmoi chattr exact sets the exact attribute on a directory exists $CHEZMOISOURCEDIR/dot_dir -chezmoi chattr exact $HOME${/}.dir +exec chezmoi chattr exact $HOME${/}.dir ! exists $CHEZMOISOURCEDIR/dot_dir exists $CHEZMOISOURCEDIR/exact_dot_dir # test that chezmoi chattr readonly sets the readonly attribute on a directory -chezmoi chattr readonly $HOME${/}.dir +exec chezmoi chattr readonly $HOME${/}.dir ! exists $CHEZMOISOURCEDIR/exact_dot_dir exists $CHEZMOISOURCEDIR/exact_readonly_dot_dir +# test that chezmoi chattr remove sets the remove attribute on a directory +exec chezmoi chattr remove $HOME${/}.dir +! exists $CHEZMOISOURCEDIR/exact_readonly_dot_dir +exists $CHEZMOISOURCEDIR/remove_exact_readonly_dot_dir + +# test that chezmoi chattr noremove removes the remove attribute on a directory +exec chezmoi chattr noremove $HOME${/}.dir +! exists $CHEZMOISOURCEDIR/remove_exact_readonly_dot_dir +exists $CHEZMOISOURCEDIR/exact_readonly_dot_dir + # test that chezmoi chattr +t sets the template attribute on a symlink exists $CHEZMOISOURCEDIR/symlink_dot_symlink -chezmoi chattr +t $HOME${/}.symlink +exec chezmoi chattr +t $HOME${/}.symlink ! exists $CHEZMOISOURCEDIR/symlink_dot_symlink exists $CHEZMOISOURCEDIR/symlink_dot_symlink.tmpl # test that chezmoi chattr -- -t removes the template attribute on a symlink -chezmoi chattr -- -t $HOME${/}.symlink +exec chezmoi chattr -- -t $HOME${/}.symlink ! exists $CHEZMOISOURCEDIR/symlink_dot_symlink.tmpl exists $CHEZMOISOURCEDIR/symlink_dot_symlink # test that chezmoi chattr -- before sets the before attribute on a script -chezmoi chattr -- before $HOME/script -! exists $CHEZMOISOURCEDIR/run_script -exists $CHEZMOISOURCEDIR/run_before_script +exec chezmoi chattr -- before $HOME/script.sh +! exists $CHEZMOISOURCEDIR/run_script.sh +exists $CHEZMOISOURCEDIR/run_before_script.sh # test that chezmoi chattr -- once sets the once attribute on a script -chezmoi chattr -- once $HOME/script -! exists $CHEZMOISOURCEDIR/run_before_script -exists $CHEZMOISOURCEDIR/run_once_before_script +exec chezmoi chattr -- once $HOME/script.sh +! exists $CHEZMOISOURCEDIR/run_before_script.sh +exists $CHEZMOISOURCEDIR/run_once_before_script.sh # test that chezmoi chattr -- after sets the after attribute on a script and removes the before attribute -chezmoi chattr -- after $HOME/script -! exists $CHEZMOISOURCEDIR/run_once_before_script -exists $CHEZMOISOURCEDIR/run_once_after_script +exec chezmoi chattr -- after $HOME/script.sh +! exists $CHEZMOISOURCEDIR/run_once_before_script.sh +exists $CHEZMOISOURCEDIR/run_once_after_script.sh # test that chezmoi chattr onchange sets the onchange attribute on a script and removes the only attribute -chezmoi chattr -- onchange $HOME/script -! exists $CHEZMOISOURCEDIR/run_once_after_script -exists $CHEZMOISOURCEDIR/run_onchange_after_script +exec chezmoi chattr -- onchange $HOME/script.sh +! exists $CHEZMOISOURCEDIR/run_once_after_script.sh +exists $CHEZMOISOURCEDIR/run_onchange_after_script.sh # test that chezmoi chattr -- -onchange removes the onchange attribute on a script -chezmoi chattr -- -onchange $HOME/script -! exists $CHEZMOISOURCEDIR/run_onchange_after_script -exists $CHEZMOISOURCEDIR/run_after_script +exec chezmoi chattr -- -onchange $HOME/script.sh +! exists $CHEZMOISOURCEDIR/run_onchange_after_script.sh +exists $CHEZMOISOURCEDIR/run_after_script.sh # test that chezmoi chattr -- -a removes the after attribute on a script -chezmoi chattr -- -a $HOME/script -! exists $CHEZMOISOURCEDIR/run_after_script -exists $CHEZMOISOURCEDIR/run_script +exec chezmoi chattr -- -a $HOME/script.sh +! exists $CHEZMOISOURCEDIR/run_after_script.sh +exists $CHEZMOISOURCEDIR/run_script.sh # test that chezmoi chattr +executable,+private,+readonly,+template sets the attributes on a modify script -chezmoi chattr +executable,+private,+readonly,+template $HOME${/}.modify +exec chezmoi chattr +executable,+private,+readonly,+template $HOME${/}.modify ! exists $CHEZMOISOURCEDIR/modify_dot_modify exists $CHEZMOISOURCEDIR/modify_private_readonly_executable_dot_modify.tmpl # test that chezmoi chattr --dry-run --verbose generates a diff when a file is renamed -chezmoi chattr --dry-run --verbose +executable $HOME${/}.file +exec chezmoi chattr --dry-run --verbose +executable $HOME${/}.file cmp stdout golden/chattr-diff +# test that chezmoi chattr --recursive noexact recurses into subdirectories +exists $CHEZMOISOURCEDIR/exact_readonly_dot_dir +exists $CHEZMOISOURCEDIR/exact_readonly_dot_dir/exact_subdir +exec chezmoi chattr --recursive noexact $HOME${/}.dir +exists $CHEZMOISOURCEDIR/readonly_dot_dir +exists $CHEZMOISOURCEDIR/readonly_dot_dir/subdir + -- golden/chattr-diff -- diff --git a/dot_file b/executable_dot_file rename from dot_file rename to executable_dot_file --- home/user/.local/share/chezmoi/run_script -- -#!/bin/sh -- home/user/.local/share/chezmoi/modify_dot_modify -- #!/bin/sh cat - +-- home/user/.local/share/chezmoi/run_script.sh -- +#!/bin/sh diff --git a/internal/cmd/testdata/scripts/chattrencrypted.txtar b/internal/cmd/testdata/scripts/chattrencrypted.txtar new file mode 100644 index 00000000000..6c2ca0d1cab --- /dev/null +++ b/internal/cmd/testdata/scripts/chattrencrypted.txtar @@ -0,0 +1,20 @@ +[!exec:age] skip 'age not found in $PATH' + +mkageconfig + +# test that chezmoi add --encrypted encrypts a file +exec chezmoi add --encrypt $HOME${/}.file +grep '-----BEGIN AGE ENCRYPTED FILE-----' $CHEZMOISOURCEDIR/encrypted_dot_file.age + +# test that chezmoi chattr noencrypted decrypts the file in the source state +exec chezmoi chattr noencrypted $HOME${/}.file +! exists $CHEZMOISOURCEDIR/encrypted_dot_file.age +cmp $CHEZMOISOURCEDIR/dot_file $HOME/.file + +# test that chezmoi chattr encrypted encrypts the file in the source state +exec chezmoi chattr encrypted $HOME${/}.file +! exists $CHEZMOISOURCEDIR/dot_file +grep '-----BEGIN AGE ENCRYPTED FILE-----' $CHEZMOISOURCEDIR/encrypted_dot_file.age + +-- home/user/.file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/completion.txt b/internal/cmd/testdata/scripts/completion.txt deleted file mode 100644 index 00f72dc19cf..00000000000 --- a/internal/cmd/testdata/scripts/completion.txt +++ /dev/null @@ -1,11 +0,0 @@ -chezmoi completion bash -stdout '# bash completion V2 for chezmoi' - -chezmoi completion fish -stdout '# fish completion for chezmoi' - -chezmoi completion powershell -stdout 'Register-ArgumentCompleter' - -chezmoi completion zsh -stdout '#compdef _chezmoi chezmoi' diff --git a/internal/cmd/testdata/scripts/completion.txtar b/internal/cmd/testdata/scripts/completion.txtar new file mode 100644 index 00000000000..5dd0dfd6f85 --- /dev/null +++ b/internal/cmd/testdata/scripts/completion.txtar @@ -0,0 +1,89 @@ +# test that chezmoi completion bash generates bash completions +exec chezmoi completion bash +stdout '# bash completion V2 for chezmoi' + +# test that chezmoi completion fish generates fish completions +exec chezmoi completion fish +stdout '# fish completion for chezmoi' + +# test that chezmoi completion powershell generates powershell completions +exec chezmoi completion powershell +stdout 'Register-ArgumentCompleter' + +# test that chezmoi completion zsh generates zsh completions +exec chezmoi completion zsh +stdout '#compdef chezmoi' + +# test that --use-builtin flags are completed +exec chezmoi __complete --use-builtin +cmp stdout golden/use-builtin-flags + +# test that autoBool values are completed +exec chezmoi __complete --color t +cmp stdout golden/auto-bool-t + +# test that entry type set values are completed +exec chezmoi __complete apply --include '' +cmp stdout golden/entry-type-set + +# test that mode values are completed +exec chezmoi __complete --mode '' +cmp stdout golden/mode + +# test that path style values are completed +exec chezmoi __complete managed --path-style '' +cmp stdout golden/path-style + +# test that write --format values are completed +exec chezmoi __complete state dump --format '' +cmp stdout golden/write-data + +# test that write --format values are completed +exec chezmoi __complete data --format '' +cmp stdout golden/write-data + +-- golden/auto-bool-t -- +t +true +:4 +-- golden/entry-type-set -- +all +always +dirs +encrypted +externals +files +noalways +nodirs +noencrypted +noexternals +nofiles +none +noremove +noscripts +nosymlinks +notemplates +remove +scripts +symlinks +templates +:6 +-- golden/mode -- +file +symlink +:4 +-- golden/path-style -- +absolute +relative +source-absolute +source-relative +:4 +-- golden/use-builtin-flags -- +--use-builtin-age Use builtin age +--use-builtin-diff Use builtin diff +--use-builtin-git Use builtin git +:4 +-- golden/write-data -- +json +yaml +:4 diff --git a/internal/cmd/testdata/scripts/completion_unix.txtar b/internal/cmd/testdata/scripts/completion_unix.txtar new file mode 100644 index 00000000000..7c0e07007a5 --- /dev/null +++ b/internal/cmd/testdata/scripts/completion_unix.txtar @@ -0,0 +1,60 @@ +[windows] skip 'UNIX only' + +# test chezmoi --include completion +exec chezmoi __complete apply --include=d +cmp stdout golden/complete-apply-include-d + +# test chezmoi --secrets completion +exec chezmoi __complete add --secrets=e +cmp stdout golden/complete-secrets-e + +# test chezmoi cat completion of targets in a directory +exec chezmoi __complete cat $HOME +cmpenv stdout golden/complete-target-home + +# test chezmoi cat completion of matching absolute targets +exec chezmoi __complete cat $HOME/.f +cmpenv stdout golden/complete-target-home-dot-f + +# test chezmoi cat completion of matching relative targets +cd $HOME +exec chezmoi __complete cat .f +cmpenv stdout $WORK/golden/complete-dot-f-in-home +cd $WORK + +# test chezmoi chattr completion of attributes +exec chezmoi __complete chattr p +cmp stdout golden/complete-attribute-p + +# test chezmoi chattr completion of targets +exec chezmoi __complete cat private $HOME +cmpenv stdout golden/complete-target-home + +-- golden/complete-apply-include-d -- +dirs +:6 +-- golden/complete-attribute-p -- +private +:4 +-- golden/complete-dot-f-in-home -- +.file +:4 +-- golden/complete-secrets-e -- +e +error +:4 +-- golden/complete-target-home -- +$HOME/.dir/ +$HOME/.dir/file +$HOME/.file +:4 +-- golden/complete-target-home-dot-f -- +$HOME/.file +:4 +-- home/user/.config/chezmoi/chezmoi.toml -- +[completion] + custom = true +-- home/user/.local/share/chezmoi/dot_dir/file -- +# contents of .dir/file +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/config.txt b/internal/cmd/testdata/scripts/config.txt deleted file mode 100644 index e23bef76246..00000000000 --- a/internal/cmd/testdata/scripts/config.txt +++ /dev/null @@ -1,35 +0,0 @@ -# test default config -chezmoi data --format=yaml -stdout 'sourceDir: .*/home/user/.local/share/chezmoi' - -# test that flags override default config -chezmoi data --format=yaml --source=/flag/source -stdout 'sourceDir: .*/flag/source' - -chhome home2/user - -# test that flags override default config -chezmoi execute-template --source=/flag/source '{{ .chezmoi.sourceDir }}' -stdout /flag/source - -# test that config files override default config -chezmoi data --format=yaml -stdout 'sourceDir: .*/config/source' - -# test that the config file can be set -chezmoi data --config=$CHEZMOICONFIGDIR/chezmoi.yaml --format=yaml -stdout 'sourceDir: .*/config2/source' - -[windows] stop 'remaining tests require /dev/stdin' - -# test that chezmoi can read the config from stdin -stdin home2/user/.config/chezmoi/chezmoi.yaml -chezmoi data --config=/dev/stdin --config-format=yaml --format=yaml -stdout 'sourceDir: .*/config2/source' - --- home2/user/.config/chezmoi/chezmoi.toml -- -color = "auto" -sourceDir = "/config/source" --- home2/user/.config/chezmoi/chezmoi.yaml -- -color: auto -sourceDir: /config2/source diff --git a/internal/cmd/testdata/scripts/config.txtar b/internal/cmd/testdata/scripts/config.txtar new file mode 100644 index 00000000000..3cdf6639125 --- /dev/null +++ b/internal/cmd/testdata/scripts/config.txtar @@ -0,0 +1,48 @@ +# test default config +exec chezmoi data --format=yaml +stdout 'sourceDir: .*/home/user/.local/share/chezmoi' + +# test that flags override default config +exec chezmoi data --format=yaml --source=/flag/source +stdout 'sourceDir: .*/flag/source' + +chhome home2/user + +# test that flags override default config +exec chezmoi execute-template --source=/flag/source '{{ .chezmoi.sourceDir }}' +stdout /flag/source + +# test that config files override default config +exec chezmoi data --format=yaml +stdout 'sourceDir: .*/config/source' + +# test that the config file can be set and can be in YAML format +exec chezmoi data --config=$HOME/.chezmoi.yaml --format=yaml +stdout 'sourceDir: .*/config2/source' + +# test that the config file can be in JSONC format +exec chezmoi data --config=$HOME/.chezmoi.jsonc --format=yaml +stdout 'sourceDir: .*/config3/source' + +# test that the cache directory can be set +exec chezmoi data --cache=/flag/cache --format=yaml +stdout 'cacheDir: .*/flag/cache' + +[windows] stop 'remaining tests require /dev/stdin' + +# test that chezmoi can read the config from stdin +stdin home2/user/.chezmoi.yaml +exec chezmoi data --config=/dev/stdin --config-format=yaml --format=yaml +stdout 'sourceDir: .*/config2/source' + +-- home2/user/.chezmoi.jsonc -- +{ + "color": "auto", // Color + "sourceDir": "/config3/source", // Source directory +} +-- home2/user/.chezmoi.yaml -- +color: auto +sourceDir: /config2/source +-- home2/user/.config/chezmoi/chezmoi.toml -- +color = "auto" +sourceDir = "/config/source" diff --git a/internal/cmd/testdata/scripts/configstate.txt b/internal/cmd/testdata/scripts/configstate.txtar similarity index 68% rename from internal/cmd/testdata/scripts/configstate.txt rename to internal/cmd/testdata/scripts/configstate.txtar index 487d6aa69e7..1cc08c5e65b 100644 --- a/internal/cmd/testdata/scripts/configstate.txt +++ b/internal/cmd/testdata/scripts/configstate.txtar @@ -1,56 +1,64 @@ # test that chezmoi init creates a config file and updates the state -chezmoi init +exec chezmoi init cmp $CHEZMOICONFIGDIR/chezmoi.toml golden/chezmoi.toml -chezmoi state dump --format=yaml +exec chezmoi state dump --format=yaml cmp stdout golden/state-dump.yaml ! stderr . # test that chezmoi apply succeeds -chezmoi apply +exec chezmoi apply ! stderr . # test that chezmoi apply prints a warning if the config file template has been changed cp golden/.chezmoi.toml.tmpl $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl -chezmoi apply +exec chezmoi apply stderr 'warning: config file template has changed' +# test that chezmoi apply does not print the warning if it is suppressed +appendline $CHEZMOICONFIGDIR/chezmoi.toml '[warnings]' +appendline $CHEZMOICONFIGDIR/chezmoi.toml ' configFileTemplateHasChanged = false' +exec chezmoi apply +! stderr . +cp golden/chezmoi.toml $CHEZMOICONFIGDIR + # test that chezmoi init re-generates the config file -chezmoi init +exec chezmoi init cmp $CHEZMOICONFIGDIR/chezmoi.toml golden/chezmoi.toml ! stderr . # test that chezmoi apply no longer prints a warning after the config file is regenerated -chezmoi apply +exec chezmoi apply ! stderr . # test that chezmoi apply --force ignores config file changes and updates the state edit $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl grep '# edited' $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl -chezmoi apply --force +exec chezmoi apply --force ! stderr . ! grep '# edited' $CHEZMOICONFIGDIR/chezmoi.toml chhome home2/user # test that chezmoi diff prints a warning when a config file template is added -chezmoi diff +exec chezmoi diff ! stderr . cp golden/chezmoi.toml $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl -chezmoi diff +exec chezmoi diff stderr 'warning: config file template has changed' # test that chezmoi diff does not print a warning when the config file template is removed rm $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl -chezmoi diff +exec chezmoi diff ! stderr . -- golden/.chezmoi.toml.tmpl -- -{{ $email := get . "email" -}} -{{ if not $email -}} -{{ $email = promptString "email" -}} -{{ end -}} +# chezmoi:template:left-delimiter="((" right-delimiter="))" +(( $email := get . "email" -)) +(( if not $email -)) +(( $email = promptString "email" -)) +(( end -)) [data] - email = {{ $email | quote }} + email = (( $email | quote )) -- golden/chezmoi.toml -- [data] email = "me@home.org" @@ -59,6 +67,11 @@ configState: configState: configTemplateContentsSHA256: af43121a524340707b84e390f510c949731177e6f2a25b3b6b11b2fc656cf8f2 entryState: {} +gitHubKeysState: {} +gitHubLatestReleaseState: {} +gitHubReleasesState: {} +gitHubTagsState: {} +gitRepoExternalState: {} scriptState: {} -- home/user/.local/share/chezmoi/.chezmoi.toml.tmpl -- [data] diff --git a/internal/cmd/testdata/scripts/create.txt b/internal/cmd/testdata/scripts/create.txtar similarity index 77% rename from internal/cmd/testdata/scripts/create.txt rename to internal/cmd/testdata/scripts/create.txtar index 29d3b693ff3..1271d9835a3 100644 --- a/internal/cmd/testdata/scripts/create.txt +++ b/internal/cmd/testdata/scripts/create.txtar @@ -1,7 +1,7 @@ # test that chezmoi apply creates a file from a template mkdir $CHEZMOISOURCEDIR cp golden/create_dot_create.tmpl $CHEZMOISOURCEDIR -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.create golden/.create [windows] skip 'UNIX only' @@ -12,9 +12,9 @@ chhome home2/user # test that chezmoi apply creates encrypted create_ targets mkageconfig mkdir $CHEZMOISOURCEDIR -chezmoi encrypt --output=$CHEZMOISOURCEDIR/create_encrypted_dot_create.age golden/.create +exec chezmoi encrypt --output=$CHEZMOISOURCEDIR/create_encrypted_dot_create.age golden/.create grep '-----BEGIN AGE ENCRYPTED FILE-----' $CHEZMOISOURCEDIR/create_encrypted_dot_create.age -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.create golden/.create chhome home3/user @@ -22,15 +22,15 @@ chhome home3/user # test that chezmoi apply creates encrypted template create_ targets mkageconfig mkdir $CHEZMOISOURCEDIR -chezmoi encrypt --output=$CHEZMOISOURCEDIR/create_encrypted_dot_create.tmpl.age golden/create_dot_create.tmpl +exec chezmoi encrypt --output=$CHEZMOISOURCEDIR/create_encrypted_dot_create.tmpl.age golden/create_dot_create.tmpl grep '-----BEGIN AGE ENCRYPTED FILE-----' $CHEZMOISOURCEDIR/create_encrypted_dot_create.tmpl.age -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.create golden/.create chhome home4/user # test that chezmoi manage does not execute template create_ targets -chezmoi managed +exec chezmoi managed cmp stdout golden/managed -- golden/.create -- diff --git a/internal/cmd/testdata/scripts/dashlane.txtar b/internal/cmd/testdata/scripts/dashlane.txtar new file mode 100644 index 00000000000..891ac6656aa --- /dev/null +++ b/internal/cmd/testdata/scripts/dashlane.txtar @@ -0,0 +1,84 @@ +[unix] chmod 755 bin/dcli +[windows] unix2dos bin/dcli.cmd +[windows] unix2dos golden/dashlane-note + +# test dashlanePassword template function +exec chezmoi execute-template '{{ (index (dashlanePassword "filter") 0).password }}' +stdout ^$ + +# test dashlaneNote template function +exec chezmoi execute-template '{{ dashlaneNote "filter" }}' +cmp stdout golden/dashlane-note + +-- bin/dcli -- +#!/bin/sh + +case "$*" in +"password --output json filter") + cat <", + "useFixedUrl": false, + "login": "", + "status": "ACCOUNT_NOT_VERIFIED", + "note": "", + "autoLogin": false, + "modificationDatetime": "", + "checked": false, + "id": "", + "anonId": "", + "localeFormat": "UNIVERSAL", + "password": "", + "creationDatetime": "", + "userModificationDatetime": "", + "lastBackupTime": "", + "autoProtected": false, + "strength": 0, + "subdomainOnly": false + } +] +EOF + ;; +"note filter") + cat < +EOF + ;; +*) + echo "error: unknown command '$*'" + exit 1 +esac +-- bin/dcli.cmd -- +@echo off +IF "%*" == "password --output json filter" ( + echo.[ + echo. { + echo. "title": "", + echo. "useFixedUrl": false, + echo. "login": "", + echo. "status": "ACCOUNT_NOT_VERIFIED", + echo. "note": "", + echo. "autoLogin": false, + echo. "modificationDatetime": "", + echo. "checked": false, + echo. "id": "", + echo. "anonId": "", + echo. "localeFormat": "UNIVERSAL", + echo. "password": "", + echo. "creationDatetime": "", + echo. "userModificationDatetime": "", + echo. "lastBackupTime": "", + echo. "autoProtected": false, + echo. "strength": 0, + echo. "subdomainOnly": false + echo. } + echo.] +) ELSE IF "%*" == "note filter" ( + echo.^ +) ELSE ( + echo error: unknown command '$*' + exit /b 1 +) +-- golden/dashlane-note -- + diff --git a/internal/cmd/testdata/scripts/data.txt b/internal/cmd/testdata/scripts/data.txtar similarity index 63% rename from internal/cmd/testdata/scripts/data.txt rename to internal/cmd/testdata/scripts/data.txtar index 377bd086364..53eb69500d2 100644 --- a/internal/cmd/testdata/scripts/data.txt +++ b/internal/cmd/testdata/scripts/data.txtar @@ -1,17 +1,17 @@ # test that chezmoi data includes data set in config file -chezmoi data +exec chezmoi data stdout '"chezmoi":' -stdout '"uniquekey": "uniqueValue"' # viper downcases uniqueKey +stdout '"uniqueKey": "uniqueValue"' # test that chezmoi data --format=json includes data set in config file -chezmoi data --format=json +exec chezmoi data --format=json stdout '"chezmoi":' -stdout '"uniquekey": "uniqueValue"' +stdout '"uniqueKey": "uniqueValue"' # test that chezmoi data --format=yaml includes data set in config file -chezmoi data --format=yaml +exec chezmoi data --format=yaml stdout 'chezmoi:' -stdout 'uniquekey: uniqueValue' +stdout 'uniqueKey: uniqueValue' -- home/user/.config/chezmoi/chezmoi.toml -- [data] diff --git a/internal/cmd/testdata/scripts/debug.txt b/internal/cmd/testdata/scripts/debug.txtar similarity index 81% rename from internal/cmd/testdata/scripts/debug.txt rename to internal/cmd/testdata/scripts/debug.txtar index 23dee7cba2f..aeeeac07871 100644 --- a/internal/cmd/testdata/scripts/debug.txt +++ b/internal/cmd/testdata/scripts/debug.txtar @@ -6,8 +6,8 @@ mksourcedir httpd www # test that chezmoi apply --debug writes logs -chezmoi encrypt --output=$CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc -chezmoi apply --debug +exec chezmoi encrypt --output=$CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc +exec chezmoi apply --debug stderr component=encryption stderr component=persistentState stderr component=sourceState diff --git a/internal/cmd/testdata/scripts/remove.txt b/internal/cmd/testdata/scripts/destroy.txtar similarity index 59% rename from internal/cmd/testdata/scripts/remove.txt rename to internal/cmd/testdata/scripts/destroy.txtar index 904b77302cf..b270d60317d 100644 --- a/internal/cmd/testdata/scripts/remove.txt +++ b/internal/cmd/testdata/scripts/destroy.txtar @@ -1,31 +1,31 @@ mkhomedir mksourcedir -# test that chezmoi remove file removes a file -chezmoi apply --force +# test that chezmoi destroy file destroys a file +exec chezmoi apply --force exists $HOME/.file -chezmoi remove --force $HOME${/}.file +exec chezmoi destroy --force $HOME${/}.file ! exists $HOME/.file -chezmoi state get --bucket=entryState --key=$WORK/home/user/.file +exec chezmoi state get --bucket=entryState --key=$WORK/home/user/.file ! stdout . -# test that chezmoi remove dir removes a directory +# test that chezmoi destroy dir destroys a directory exists $HOME/.dir -chezmoi remove --force $HOME${/}.dir +exec chezmoi destroy --force $HOME${/}.dir ! exists $HOME/.dir -# test that if any chezmoi remove stops on any error +# test that if any chezmoi destroy stops on any error exists $HOME/.executable -! chezmoi remove --force $HOME${/}.newfile $HOME${/}.executable -stderr 'not in source state' +! exec chezmoi destroy --force $HOME${/}.newfile $HOME${/}.executable +stderr 'not managed' exists $HOME/.executable chhome home2/user -# test that chezmoi apply removes a file and a directory +# test that chezmoi apply destroys a file and a directory exists $HOME/.file exists $HOME/.dir -chezmoi apply +exec chezmoi apply ! exists $HOME/.file ! exists $HOME/.dir @@ -34,19 +34,19 @@ chhome home3/user # test that chezmoi apply with .chezmoiremove with star works on destination dir with trailing slash exists $HOME/.star-file exists $HOME/.star-dir -chezmoi apply --destination=$HOME/ +exec chezmoi apply --destination=$HOME/ ! exists $HOME/.star-file ! exists $HOME/.star-dir -- home2/user/.dir/.keep -- -- home2/user/.file -- # contents of .file --- home2/user/.local/share/chezmoi/remove_dot_file -- -- home2/user/.local/share/chezmoi/remove_dot_dir -- --- home3/user/.star-dir/.keep -- --- home3/user/.star-file -- -# contents of .star-file +-- home2/user/.local/share/chezmoi/remove_dot_file -- -- home3/user/.local/share/chezmoi/.chezmoiremove -- .*-dir/ .*-file +-- home3/user/.star-dir/.keep -- +-- home3/user/.star-file -- +# contents of .star-file diff --git a/internal/cmd/testdata/scripts/diff.txt b/internal/cmd/testdata/scripts/diff.txtar similarity index 67% rename from internal/cmd/testdata/scripts/diff.txt rename to internal/cmd/testdata/scripts/diff.txtar index 246cb64a18f..7be3b521ed1 100644 --- a/internal/cmd/testdata/scripts/diff.txt +++ b/internal/cmd/testdata/scripts/diff.txtar @@ -5,67 +5,67 @@ mkhomedir mksourcedir # test that chezmoi diff generates no output when the source and destination states are equal -chezmoi diff +exec chezmoi diff ! stdout . # test that chezmoi diff generates a diff when a file is added to the source state cp golden/dot_newfile $CHEZMOISOURCEDIR/dot_newfile -chezmoi diff -[!windows] cmp stdout golden/add-newfile-diff-unix -[windows] cmp stdout golden/add-newfile-diff-windows +exec chezmoi diff +[unix] cmp stdout golden/add-newfile-diff-unix.diff +[windows] cmp stdout golden/add-newfile-diff-windows.diff rm $CHEZMOISOURCEDIR/dot_newfile # test that chezmoi diff generates a diff when a file is edited edit $HOME/.file -chezmoi diff -[!windows] cmp stdout golden/modify-file-diff-unix -[windows] cmp stdout golden/modify-file-diff-windows -chezmoi apply --force $HOME${/}.file +exec chezmoi diff +[unix] cmp stdout golden/modify-file-diff-unix.diff +[windows] cmp stdout golden/modify-file-diff-windows.diff +exec chezmoi apply --force $HOME${/}.file # test chezmoi diff --reverse edit $HOME/.file -chezmoi diff --reverse -[!windows] cmp stdout golden/modify-file-diff-reverse-unix -[windows] cmp stdout golden/modify-file-diff-reverse-windows -chezmoi apply --force $HOME${/}.file +exec chezmoi diff --reverse +[unix] cmp stdout golden/modify-file-diff-reverse-unix.diff +[windows] cmp stdout golden/modify-file-diff-reverse-windows.diff +exec chezmoi apply --force $HOME${/}.file # test that chezmoi diff generates a diff when a file is removed from the destination directory rm $HOME/.file -chezmoi diff -[!windows] cmp stdout golden/restore-file-diff-unix -[windows] cmp stdout golden/restore-file-diff-windows -chezmoi apply --force $HOME${/}.file +exec chezmoi diff +[unix] cmp stdout golden/restore-file-diff-unix.diff +[windows] cmp stdout golden/restore-file-diff-windows.diff +exec chezmoi apply --force $HOME${/}.file # test that chezmoi diff generates a diff when a directory is removed from the destination directory rm $HOME/.dir -chezmoi diff --recursive=false $HOME${/}.dir -[!windows] cmp stdout golden/restore-dir-diff-unix -[windows] cmp stdout golden/restore-dir-diff-windows -chezmoi apply --force $HOME${/}.dir +exec chezmoi diff --recursive=false $HOME${/}.dir +[unix] cmp stdout golden/restore-dir-diff-unix.diff +[windows] cmp stdout golden/restore-dir-diff-windows.diff +exec chezmoi apply --force $HOME${/}.dir # test that chezmoi diff generates a diff when the actual state is a file and the target state is a symlink rm $HOME/.symlink cp golden/.file $HOME/.symlink -chezmoi diff -[!windows] cmp stdout golden/symlink-file-diff-unix +exec chezmoi diff +[unix] cmp stdout golden/symlink-file-diff-unix.diff # [windows] cmp stdout golden/symlink-file-diff-windows # FIXME -chezmoi apply --force $HOME${/}.symlink +exec chezmoi apply --force $HOME${/}.symlink [windows] stop 'remaining tests use file modes' # test that chezmoi diff generates a diff when a file's permissions are changed chmod 777 $HOME/.file -chezmoi diff -cmp stdout golden/chmod-file-diff -chezmoi apply --force $HOME${/}.file +exec chezmoi diff +cmp stdout golden/chmod-file-diff.diff +exec chezmoi apply --force $HOME${/}.file # test that chezmoi diff generates a diff when a directory's permissions are changed chmod 700 $HOME/.dir -chezmoi diff -cmp stdout golden/chmod-dir-diff -chezmoi apply --force --recursive=false $HOME${/}.dir +exec chezmoi diff +cmp stdout golden/chmod-dir-diff.diff +exec chezmoi apply --force --recursive=false $HOME${/}.dir --- golden/add-newfile-diff-unix -- +-- golden/add-newfile-diff-unix.diff -- diff --git a/.newfile b/.newfile new file mode 100644 index 0000000000000000000000000000000000000000..06e05235fdd12fd5c367b6d629fef94536c85525 @@ -73,7 +73,7 @@ index 0000000000000000000000000000000000000000..06e05235fdd12fd5c367b6d629fef945 +++ b/.newfile @@ -0,0 +1 @@ +# contents of .newfile --- golden/add-newfile-diff-windows -- +-- golden/add-newfile-diff-windows.diff -- diff --git a/.newfile b/.newfile new file mode 100666 index 0000000000000000000000000000000000000000..06e05235fdd12fd5c367b6d629fef94536c85525 @@ -81,39 +81,61 @@ index 0000000000000000000000000000000000000000..06e05235fdd12fd5c367b6d629fef945 +++ b/.newfile @@ -0,0 +1 @@ +# contents of .newfile --- golden/modify-file-diff-unix -- +-- golden/chmod-dir-diff.diff -- +diff --git a/.dir b/.dir +old mode 40700 +new mode 40755 +-- golden/chmod-file-diff.diff -- diff --git a/.file b/.file -index 5d2730a8850a2db479af83de87cc8345437aef06..8a52cb9ce9551221716a53786ad74104c5902362 100644 +old mode 100777 +new mode 100644 +-- golden/dot_newfile -- +# contents of .newfile +-- golden/modify-file-diff-reverse-unix.diff -- +diff --git a/.file b/.file +index 8a52cb9ce9551221716a53786ad74104c5902362..5d2730a8850a2db479af83de87cc8345437aef06 100644 --- a/.file +++ b/.file -@@ -1,2 +1 @@ +@@ -1 +1,2 @@ # contents of .file --# edited --- golden/modify-file-diff-windows -- ++# edited +-- golden/modify-file-diff-reverse-windows.diff -- diff --git a/.file b/.file -index 5d2730a8850a2db479af83de87cc8345437aef06..8a52cb9ce9551221716a53786ad74104c5902362 100666 +index 8a52cb9ce9551221716a53786ad74104c5902362..5d2730a8850a2db479af83de87cc8345437aef06 100666 --- a/.file +++ b/.file -@@ -1,2 +1 @@ +@@ -1 +1,2 @@ # contents of .file --# edited --- golden/modify-file-diff-reverse-unix -- ++# edited +-- golden/modify-file-diff-unix.diff -- diff --git a/.file b/.file -index 8a52cb9ce9551221716a53786ad74104c5902362..5d2730a8850a2db479af83de87cc8345437aef06 100644 +index 5d2730a8850a2db479af83de87cc8345437aef06..8a52cb9ce9551221716a53786ad74104c5902362 100644 --- a/.file +++ b/.file -@@ -1 +1,2 @@ +@@ -1,2 +1 @@ # contents of .file -+# edited --- golden/modify-file-diff-reverse-windows -- +-# edited +-- golden/modify-file-diff-windows.diff -- diff --git a/.file b/.file -index 8a52cb9ce9551221716a53786ad74104c5902362..5d2730a8850a2db479af83de87cc8345437aef06 100666 +index 5d2730a8850a2db479af83de87cc8345437aef06..8a52cb9ce9551221716a53786ad74104c5902362 100666 --- a/.file +++ b/.file -@@ -1 +1,2 @@ +@@ -1,2 +1 @@ # contents of .file -+# edited --- golden/restore-file-diff-unix -- +-# edited +-- golden/restore-dir-diff-unix.diff -- +diff --git a/.dir b/.dir +new file mode 40755 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +--- /dev/null ++++ b/.dir +-- golden/restore-dir-diff-windows.diff -- +diff --git a/.dir b/.dir +new file mode 40777 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +--- /dev/null ++++ b/.dir +-- golden/restore-file-diff-unix.diff -- diff --git a/.file b/.file new file mode 100644 index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 @@ -121,7 +143,7 @@ index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104 +++ b/.file @@ -0,0 +1 @@ +# contents of .file --- golden/restore-file-diff-windows -- +-- golden/restore-file-diff-windows.diff -- diff --git a/.file b/.file new file mode 100666 index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 @@ -129,19 +151,7 @@ index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104 +++ b/.file @@ -0,0 +1 @@ +# contents of .file --- golden/restore-dir-diff-unix -- -diff --git a/.dir b/.dir -new file mode 40755 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 ---- /dev/null -+++ b/.dir --- golden/restore-dir-diff-windows -- -diff --git a/.dir b/.dir -new file mode 40777 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 ---- /dev/null -+++ b/.dir --- golden/symlink-file-diff-unix -- +-- golden/symlink-file-diff-unix.diff -- diff --git a/.symlink b/.symlink deleted file mode 100644 index 8a52cb9ce9551221716a53786ad74104c5902362..0000000000000000000000000000000000000000 @@ -158,15 +168,3 @@ index 8a52cb9ce9551221716a53786ad74104c5902362..9b91fdbb83798a67fbbc5cc4f120c3f7 @@ -1 +1 @@ -# contents of .file +.dir/subdir/file --- golden/dot_newfile -- -# contents of .newfile --- golden/chmod-file-diff -- -diff --git a/.file b/.file -old mode 100777 -new mode 100644 --- golden/chmod-dir-diff -- -diff --git a/.dir b/.dir -old mode 40700 -new mode 40755 --- golden/dot_newfile -- -# contents of .newfile diff --git a/internal/cmd/testdata/scripts/diff_unix.txt b/internal/cmd/testdata/scripts/diff_unix.txt deleted file mode 100644 index cd35076d6cc..00000000000 --- a/internal/cmd/testdata/scripts/diff_unix.txt +++ /dev/null @@ -1,41 +0,0 @@ -[windows] skip 'UNIX only' - -# test that chezmoi diff invokes diff.command when configured -chezmoi diff -stdout ^${HOME@R}/\.file\s+${WORK@R}/.*/\.file$ - -# test that chezmoi diff --use-builtin-diff uses the builtin diff even if diff.command is configured -chezmoi diff --use-builtin-diff -cmp stdout golden/diff - -# test that chezmoi diff --reverse reverses the order of arguments -chezmoi diff --reverse -stdout ^${WORK@R}/.*/\.file\s+${HOME@R}/\.file$ - -chhome home2/user - -# test that chezmoi diff appends the destination and target paths if diff.args does not contain any templates -chezmoi diff -stdout ^arg\s+${HOME@R}/\.file\s+${WORK@R}/.*/\.file$ - --- golden/diff -- -diff --git a/.file b/.file -index bd729e8ee3cc005444c67dc77eed60016886b5e0..b508963510528ab709627ec448026a10a64c72ef 100644 ---- a/.file -+++ b/.file -@@ -1 +1 @@ --# destination contents of .file -+# target contents of .file --- home/user/.config/chezmoi/chezmoi.toml -- -[diff] - command = "echo" --- home/user/.file -- -# destination contents of .file --- home/user/.local/share/chezmoi/dot_file -- -# target contents of .file --- home2/user/.config/chezmoi/chezmoi.toml -- -[diff] - command = "echo" - args = ["arg"] --- home2/user/.local/share/chezmoi/dot_file -- -# source diff --git a/internal/cmd/testdata/scripts/diffcommand_unix.txtar b/internal/cmd/testdata/scripts/diffcommand_unix.txtar new file mode 100644 index 00000000000..b2be75837fd --- /dev/null +++ b/internal/cmd/testdata/scripts/diffcommand_unix.txtar @@ -0,0 +1,120 @@ +[windows] skip 'UNIX only' + +chmod 755 bin/diff-pager + +# test that chezmoi diff invokes diff.command when configured +exec chezmoi diff +stdout ^${HOME@R}/\.file\s+${WORK@R}/.*/\.file$ + +# test that chezmoi diff --use-builtin-diff uses the builtin diff even if diff.command is configured +exec chezmoi diff --use-builtin-diff +[umask:002] cmp stdout golden/diff-umask-002.diff +[umask:022] cmp stdout golden/diff-umask-022.diff + +# test that chezmoi diff --reverse reverses the order of arguments +exec chezmoi diff --reverse +stdout ^${WORK@R}/.*/\.file\s+${HOME@R}/\.file$ + +# test that chezmoi apply --verbose uses diff.command +exec chezmoi apply --verbose +stdout ^${HOME@R}/\.file\s+${WORK@R}/.*/\.file$ + +chhome home2/user + +# test that chezmoi diff appends the destination and target paths if diff.args does not contain any templates +exec chezmoi diff +stdout ^arg\s+${HOME@R}/\.file\s+${WORK@R}/.*/\.file$ + +# test that chezmoi apply --verbose uses diff.command +exec chezmoi apply --verbose +stdout ^arg\s+${HOME@R}/\.file\s+${WORK@R}/.*/\.file$ +exists $HOME/.file + +chhome home3/user + +# test that chezmoi diff ignores exit code 1 from diff.command if the files differ +exec chezmoi diff + +chhome home4/user + +# test that chezmoi diff does not ignore exit code 2 from diff.command +! exec chezmoi diff +stderr 'exit status 2' + +chhome home5/user + +# test that chezmoi diff does not invoke diff.command for directories if directories are excluded +exec chezmoi diff +stdout \.file +! stdout \.dir + +chhome home6/user + +# test that chezmoi diff does not invoke the diff pager when there is no diff +exec chezmoi diff +! stdout . + +# test that chezmoi diff does invoke the diff pager when there is a diff +cp golden/dot_file $CHEZMOISOURCEDIR +exec chezmoi diff +stdout diff-pager + +-- bin/diff-pager -- +#!/bin/sh + +echo diff-pager +-- golden/diff-umask-002.diff -- +diff --git a/.file b/.file +index bd729e8ee3cc005444c67dc77eed60016886b5e0..b508963510528ab709627ec448026a10a64c72ef 100664 +--- a/.file ++++ b/.file +@@ -1 +1 @@ +-# destination contents of .file ++# target contents of .file +-- golden/diff-umask-022.diff -- +diff --git a/.file b/.file +index bd729e8ee3cc005444c67dc77eed60016886b5e0..b508963510528ab709627ec448026a10a64c72ef 100644 +--- a/.file ++++ b/.file +@@ -1 +1 @@ +-# destination contents of .file ++# target contents of .file +-- golden/dot_file -- +# contents of .file +-- home/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "echo" +-- home/user/.file -- +# destination contents of .file +-- home/user/.local/share/chezmoi/dot_file -- +# target contents of .file +-- home2/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "echo" + args = ["arg"] +-- home2/user/.file -- +# destination contents of .file +-- home2/user/.local/share/chezmoi/dot_file -- +# target contents of .file +-- home3/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "false" +-- home3/user/.local/share/chezmoi/dot_file -- +# target contents of .file +-- home4/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "sh" + args = ["-c", "exit 2"] +-- home4/user/.local/share/chezmoi/dot_file -- +# target contents of .file +-- home5/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "echo" + exclude = ["dirs"] +-- home5/user/.local/share/chezmoi/dot_dir/.keep -- +-- home5/user/.local/share/chezmoi/dot_file -- +# contents of .file +-- home6/user/.config/chezmoi/chezmoi.toml -- +[diff] + pager = "diff-pager" +-- home6/user/.local/share/chezmoi/.keep -- diff --git a/internal/cmd/testdata/scripts/diffcommand_windows.txtar b/internal/cmd/testdata/scripts/diffcommand_windows.txtar new file mode 100644 index 00000000000..558544cc178 --- /dev/null +++ b/internal/cmd/testdata/scripts/diffcommand_windows.txtar @@ -0,0 +1,17 @@ +[unix] skip 'Windows only' + +# test that chezmoi diff invokes diff.command when configured +exec chezmoi diff +stdout ^arg1\s+.*/\.file\s+.*/\.file\r$ + +-- bin/diff.cmd -- +@echo off +echo %* +-- home/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "diff" + args = ["arg1"] +-- home/user/.file -- +# destination contents of .file +-- home/user/.local/share/chezmoi/dot_file -- +# target contents of .file diff --git a/internal/cmd/testdata/scripts/docs.txt b/internal/cmd/testdata/scripts/docs.txt deleted file mode 100644 index 525d2939925..00000000000 --- a/internal/cmd/testdata/scripts/docs.txt +++ /dev/null @@ -1,14 +0,0 @@ -chezmoi docs -stdout 'chezmoi reference manual' - -chezmoi docs faq -stdout 'chezmoi frequently asked questions' - -chezmoi docs quickstart -stdout 'chezmoi quick start guide' - -! chezmoi docs c -stderr 'ambiguous pattern' - -! chezmoi docs z -stderr 'no matching files' diff --git a/internal/cmd/testdata/scripts/doctor_unix.txt b/internal/cmd/testdata/scripts/doctor_unix.txtar similarity index 60% rename from internal/cmd/testdata/scripts/doctor_unix.txt rename to internal/cmd/testdata/scripts/doctor_unix.txtar index 7b6d0d1df0b..5c611c00765 100644 --- a/internal/cmd/testdata/scripts/doctor_unix.txt +++ b/internal/cmd/testdata/scripts/doctor_unix.txtar @@ -2,31 +2,46 @@ chmod 755 bin/age chmod 755 bin/bw +chmod 755 bin/bws +chmod 755 bin/dcli +chmod 755 bin/doppler chmod 755 bin/git chmod 755 bin/gopass chmod 755 bin/gpg -chmod 755 bin/keepassxc +chmod 755 bin/keepassxc-cli +chmod 755 bin/keeper chmod 755 bin/lpass chmod 755 bin/op chmod 755 bin/pass +chmod 755 bin/ph chmod 755 bin/pinentry +chmod 755 bin/rbw chmod 755 bin/secret +chmod 755 bin/shell chmod 755 bin/vault chmod 755 bin/vimdiff +chmod 755 bin/vlt mkhomedir mksourcedir # test that chezmoi doctor behaves as expected -chezmoi doctor +exec chezmoi doctor stdout '^ok\s+version\s+' +stdout '^\w+\s+latest-version\s+' stdout '^ok\s+os-arch\s+' -stdout '^warning\s+config-file\s+.*multiple config files' +! stdout '^\S+\s+systeminfo\s+' +stdout '^ok\s+uname\s+' +stdout '^ok\s+config-file\s+' stdout '^ok\s+source-dir\s+' -stdout '^ok\s+suspicious-entries\s+' +stdout '^warning\s+suspicious-entries\s+' stdout '^ok\s+dest-dir\s+' -stdout '^ok\s+shell\s+' +stdout '^ok\s+shell-command\s+' +stdout '^ok\s+shell-args\s+' +stdout '^ok\s+cd-command\s+' +stdout '^ok\s+cd-args\s+' stdout '^ok\s+edit-command\s+' +stdout '^ok\s+edit-args\s+' stdout '^ok\s+git-command\s+' stdout '^ok\s+merge-command\s+' stdout '^warning\s+age-command\s+' @@ -34,18 +49,25 @@ stdout '^ok\s+gpg-command\s+' stdout '^ok\s+pinentry-command\s+' stdout '^ok\s+1password-command\s+' stdout '^ok\s+bitwarden-command\s+' +stdout '^ok\s+bitwarden-secrets-command\s+' +stdout '^ok\s+dashlane-command\s+' +stdout '^ok\s+doppler-command\s+' stdout '^ok\s+gopass-command\s+' stdout '^ok\s+keepassxc-command\s+' stdout '^info\s+keepassxc-db\s+' +stdout '^ok\s+keeper-command\s+' +stdout '^ok\s+passhole-command\s+' stdout '^ok\s+lastpass-command\s+' stdout '^ok\s+pass-command\s+' +stdout '^ok\s+rbw-command\s+' stdout '^ok\s+vault-command\s+' +stdout '^ok\s+vlt-command\s+' stdout '^ok\s+secret-command\s+' chhome home2/user # test that chezmoi doctor warns about missing directories on an empty system -! chezmoi doctor +! exec chezmoi doctor stdout '^ok\s+config-file\s+' stdout '^error\s+source-dir\s+' stdout '^ok\s+suspicious-entries\s+' @@ -53,7 +75,20 @@ stdout '^ok\s+suspicious-entries\s+' chhome home3/user # test that chezmoi doctor warns about suspicious entries -chezmoi doctor +exec chezmoi doctor +stdout '^warning\s+suspicious-entries\s+' + +chhome home4/user + +# test that chezmoi doctor does not print a warning about multiple config files +! exec chezmoi doctor +stdout '^warning\s+config-file\s+.*multiple config files' +! stderr . + +chhome home5/user + +# test that chezmoi doctor warns about encrypted files +exec chezmoi doctor stdout '^warning\s+suspicious-entries\s+' -- bin/age -- @@ -63,7 +98,21 @@ echo "(devel)" -- bin/bw -- #!/bin/sh -echo "1.12.1" +echo '(node:84023) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.' 1>&2 +echo '(Use `node --trace-deprecation ...` to show where the warning was created)' 1>&2 +echo "2023.10.0" +-- bin/bws -- +#!/bin/sh + +echo "Bitwarden Secrets CLI 0.3.0" +-- bin/dcli -- +#!/bin/sh + +echo 1.0.0 +-- bin/doppler -- +#!/bin/sh + +echo "v3.65.1" -- bin/git -- #!/bin/sh @@ -89,10 +138,14 @@ 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/keepassxc-cli -- #!/bin/sh -echo "2.5.4" +echo "2.7.0" +-- bin/keeper -- +#!/bin/sh + +echo "Commander Version: 16.6.4 (Current version)" -- bin/lpass -- #!/bin/sh @@ -100,7 +153,7 @@ echo "LastPass CLI v1.3.3.GIT" -- bin/op -- #!/bin/sh -echo "1.3.0" +echo "2.0.0" -- bin/pass -- #!/bin/sh @@ -114,6 +167,10 @@ echo "= Jason@zx2c4.com =" echo "= =" echo "= http://www.passwordstore.org/ =" echo "============================================" +-- bin/ph -- +#!/bin/sh + +echo 1.10.0 -- bin/pinentry -- #!/bin/sh @@ -122,20 +179,37 @@ echo "Copyright (C) 2016 g10 Code GmbH" echo "License GPLv2+: GNU GPL version 2 or later " echo "This is free software: you are free to change and redistribute it." echo "There is NO WARRANTY, to the extent permitted by law." +-- bin/rbw -- +#!/bin/sh + +echo rbw 1.7.0 -- bin/secret -- #!/bin/sh +-- bin/shell -- +#!/bin/sh -- bin/vault -- #!/bin/sh echo "Vault v1.5.5 ('f5d1ddb3750e7c28e25036e1ef26a4c02379fc01+CHANGES')" -- bin/vimdiff -- #!/bin/sh +-- bin/vlt -- +#!/bin/sh + +echo "0.2.1, git sha (8d9af42c8b98c9527741a239b23a3e384812f514), go1.20.4 arm64" -- home/user/.config/chezmoi/chezmoi.toml -- -[keepassxc] - command = "keepassxc" [pinentry] command = "pinentry" [secret] command = "secret" --- home/user/.config/chezmoi/chezmoi.yaml -- +-- home/user/.local/share/chezmoi/.chezmoidata.json -- +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +-- home/user/.local/share/chezmoi/.chezmoiscripts/.keep -- +-- home/user/.local/share/chezmoi/dot_config/chezmoi/chezmoi.toml.tmpl -- -- home3/user/.local/share/chezmoi/.chezmoisuspicious -- +-- home4/user/.config/chezmoi/chezmoi.json -- +-- home4/user/.config/chezmoi/chezmoi.yaml -- +-- home5/user/.config/chezmoi/chezmoi.toml -- +[age] + suffix = ".age-encrypted" +-- home5/user/.local/share/chezmoi/dot_config/chezmoi/encrypted_chezmoi.toml.age-encrypted -- diff --git a/internal/cmd/testdata/scripts/doctor_windows.txtar b/internal/cmd/testdata/scripts/doctor_windows.txtar new file mode 100644 index 00000000000..a05a4910d41 --- /dev/null +++ b/internal/cmd/testdata/scripts/doctor_windows.txtar @@ -0,0 +1,7 @@ +[unix] skip 'Windows only' + +mksourcedir + +# test chezmoi doctor +exec chezmoi doctor +stdout '^ok\s+systeminfo\s+' diff --git a/internal/cmd/testdata/scripts/doppler.txtar b/internal/cmd/testdata/scripts/doppler.txtar new file mode 100644 index 00000000000..c0ff3eb15f3 --- /dev/null +++ b/internal/cmd/testdata/scripts/doppler.txtar @@ -0,0 +1,151 @@ +[unix] chmod 755 bin/doppler +[windows] unix2dos bin/doppler.cmd + +# test doppler template function (global configuration) +exec chezmoi execute-template '{{ doppler "PASSWORD_123"}}' +stdout ^staplebatteryhorsecorrect$ + +# test doppler template function with project and config arguments (supplied configuration) +exec chezmoi execute-template '{{ doppler "PASSWORD" "project" "config" }}' +stdout ^correcthorsebatterystaple$ + +# test doppler template function with empty project and config arguments (global configuration) +exec chezmoi execute-template '{{ doppler "PASSWORD" "" "" }}' +stdout ^correcthorsebatterystaple$ + +# test dopplerProjectJson template function with project and config arguments (supplied configuration) +exec chezmoi execute-template '{{ (dopplerProjectJson "project" "config").PASSWORD_123 }}' +stdout ^staplebatteryhorsecorrect$ + +# test dopplerProjectJson template function with JSON secret piped to fromJson function, project and config arguments +exec chezmoi execute-template '{{ ((dopplerProjectJson "project" "config").JSON_SECRET | fromJson).created_by.email }}' +stdout ^user@example\.com$ + +# test dopplerProjectJson template function with project and empty config arguments (global configuration) +exec chezmoi execute-template '{{ (dopplerProjectJson "project" "").PASSWORD }}' +stdout ^correcthorsebatterystaple$ + +# test dopplerProjectJson template function with empty project and empty config arguments (global configuration) +exec chezmoi execute-template '{{ (dopplerProjectJson "" "").PASSWORD }}' +stdout ^correcthorsebatterystaple$ + +# test dopplerProjectJson template function without project and config arguments (global configuration) +exec chezmoi execute-template '{{ dopplerProjectJson.PASSWORD }}' +stdout ^correcthorsebatterystaple$ + +chhome home3/user + +# test doppler template function with default project and config arguments (chezmoi configuration) +exec chezmoi execute-template '{{ doppler "PASSWORD" }}' +stdout ^default-project-password$ + +# test doppler template function with project and default config arguments (chezmoi configuration) +exec chezmoi execute-template '{{ doppler "PASSWORD" "other-project" }}' +stdout ^other-project-password$ + +# test doppler template function with project and default config arguments (supplied configuration) +exec chezmoi execute-template '{{ doppler "PASSWORD" "project" "config" }}' +stdout ^correcthorsebatterystaple$ + +# test dopplerProjectJson template function with project and default config arguments (chezmoi configuration) +exec chezmoi execute-template '{{ (dopplerProjectJson "default-project").DOPPLER_CONFIG }}' +stdout ^default-config$ + +# test dopplerProjectJson template function with default project and config arguments (chezmoi configuration) +exec chezmoi execute-template '{{ (dopplerProjectJson).DOPPLER_PROJECT }}' +stdout ^default-project$ + +-- bin/doppler -- +#!/bin/sh + +case "$*" in +"secrets download --json --no-file --project project --config config"|"secrets download --json --no-file --project project"|"secrets download --json --no-file") + cat < $CHEZMOISOURCEDIR +exec chezmoi apply --source=$HOME${/}.chezmoi +cmp $HOME/.file golden/.file + +# test that adding a directory ending in a slash only adds the directory once +mkdir $HOME/.dir +exec chezmoi add $HOME${/}.dir/ +! exists $CHEZMOISOURCEDIR/dot_dir/dot_dir + +chhome home2/user + +# test that chezmoi reports an inconsistent state error when a file should be both removed and present +! exec chezmoi apply +stderr 'chezmoi: \.file: inconsistent state' + +chhome home3/user + +# test that chezmoi reports an error if there is a .chezmoi* file in the .chezmoitemplates directory +! exec chezmoi status +stderr 'not allowed in \.chezmoitemplates directory' + +# test that chezmoi data returns an error if an unknown read format is specified +! exec chezmoi init --config-format=yml +stderr 'invalid or unsupported data format' + +# test that chezmoi data returns an error if an unknown write format is specified +! exec chezmoi data --format=yml +stderr 'invalid or unsupported data format' + +skip 'FIXME make the following test pass' + +chhome home5/user + +# test that chezmoi reports an inconsistent state error when a file should be both removed and present, even if the file is not already present +! exec chezmoi apply +stderr 'chezmoi: \.file: inconsistent state + +-- golden/.file -- +# contents of .file +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file +-- home2/user/.file -- +# contents of .file +-- home2/user/.local/share/chezmoi/.chezmoiremove -- +.file +-- home2/user/.local/share/chezmoi/dot_file -- +# contents of .file +-- home3/user/.local/share/chezmoi/.chezmoitemplates/.chezmoiignore -- +-- home5/user/.local/share/chezmoi/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/edgecases.txt b/internal/cmd/testdata/scripts/edgecasesumask.txtar similarity index 56% rename from internal/cmd/testdata/scripts/edgecases.txt rename to internal/cmd/testdata/scripts/edgecasesumask.txtar index 4b6c10dc291..74bfa790fdd 100644 --- a/internal/cmd/testdata/scripts/edgecases.txt +++ b/internal/cmd/testdata/scripts/edgecasesumask.txtar @@ -1,41 +1,26 @@ +[!umask:022] skip + mkhomedir # test that chezmoi add --dry-run does not modify anything -chezmoi add --dry-run $HOME${/}.file +exec 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 +exec chezmoi add $HOME${/}.file exists $CHEZMOICONFIGDIR/chezmoistate.boltdb exists $CHEZMOISOURCEDIR/dot_file -chezmoi state dump +exec chezmoi state dump stdout 634a4dd193c7b3b926d2e08026aa81a416fd41cec52854863b974af422495663 # sha256sum of "# contents of .file\n" # test that chezmoi apply updates the state if the target and destination states match, even if the last written state does not edit $CHEZMOISOURCEDIR/dot_file edit $HOME/.file cmp $HOME/.file $CHEZMOISOURCEDIR/dot_file -chezmoi apply --dry-run $HOME${/}.file -chezmoi state dump +exec chezmoi apply --dry-run $HOME${/}.file +exec chezmoi state dump ! stdout 2e9dd6a2a8c15b20d4b0882d4c0fb8c7eea4e8ece46818090b387132f9f84c34 # sha256sum of "# contents of .file\n# edited\n" -chezmoi apply $HOME${/}.file -chezmoi state dump +exec chezmoi apply $HOME${/}.file +exec chezmoi state dump stdout 2e9dd6a2a8c15b20d4b0882d4c0fb8c7eea4e8ece46818090b387132f9f84c34 # sha256sum of "# contents of .file\n# edited\n" - -chhome home2/user - -# test that the source directory can be a symlink to another directory -symlink $HOME/.chezmoi -> $CHEZMOISOURCEDIR -chezmoi apply --source=$HOME${/}.chezmoi -cmp $HOME/.file golden/.file - -# test that adding a directory ending in a slash only adds the directory once -mkdir $HOME/.dir -chezmoi add $HOME${/}.dir/ -! exists $CHEZMOISOURCEDIR/dot_dir/dot_dir - --- golden/.file -- -# contents of .file --- home2/user/.local/share/chezmoi/dot_file -- -# contents of .file diff --git a/internal/cmd/testdata/scripts/edit.txt b/internal/cmd/testdata/scripts/edit.txtar similarity index 73% rename from internal/cmd/testdata/scripts/edit.txt rename to internal/cmd/testdata/scripts/edit.txtar index d2776d73d22..a8b13bc4cf7 100644 --- a/internal/cmd/testdata/scripts/edit.txt +++ b/internal/cmd/testdata/scripts/edit.txtar @@ -2,48 +2,48 @@ mkhomedir mksourcedir # test that chezmoi edit edits a single file -chezmoi edit $HOME${/}.file +exec chezmoi edit $HOME${/}.file grep -count=1 '# edited' $CHEZMOISOURCEDIR/dot_file ! grep '# edited' $HOME/.file # test that chezmoi edit --apply applies the edit. -chezmoi edit --apply --force $HOME${/}.file +exec chezmoi edit --apply --force $HOME${/}.file grep -count=2 '# edited' $CHEZMOISOURCEDIR/dot_file grep -count=2 '# edited' $HOME/.file # test that chezmoi edit edits a symlink -chezmoi edit $HOME${/}.symlink +exec chezmoi edit $HOME${/}.symlink grep -count=1 '# edited' $CHEZMOISOURCEDIR/symlink_dot_symlink # test that chezmoi edit edits a script -chezmoi edit $HOME${/}script -grep -count=1 '# edited' $CHEZMOISOURCEDIR/run_script +exec chezmoi edit $HOME${/}script.sh +grep -count=1 '# edited' $CHEZMOISOURCEDIR/run_script.sh # test that chezmoi edit edits a file and a symlink -chezmoi edit $HOME${/}.file $HOME${/}.symlink +exec chezmoi edit $HOME${/}.file $HOME${/}.symlink grep -count=3 '# edited' $CHEZMOISOURCEDIR/dot_file grep -count=2 '# edited' $CHEZMOISOURCEDIR/symlink_dot_symlink # test that chezmoi edit edits the working tree -chezmoi edit +exec chezmoi edit exists $CHEZMOISOURCEDIR/.edited # test that chezmoi edit edits a directory -[!windows] chezmoi edit $HOME${/}.dir -[!windows] exists $CHEZMOISOURCEDIR/dot_dir/.edited +[unix] exec chezmoi edit $HOME${/}.dir +[unix] exists $CHEZMOISOURCEDIR/dot_dir/.edited chhome home2/user # test that chezmoi edit edits a file when the working tree and the source dir are different -chezmoi edit $HOME${/}.file +exec chezmoi edit $HOME${/}.file grep -count=1 '# edited' $CHEZMOISOURCEDIR/home/dot_file # test that chezmoi edit edits the working tree when working tree and the source dir are different -chezmoi edit +exec chezmoi edit exists $CHEZMOISOURCEDIR/.edited ! exists $CHEZMOISOURCEDIR/home/.edited --- home/user/.local/share/chezmoi/run_script -- +-- home/user/.local/share/chezmoi/run_script.sh -- #!/bin/sh -- home2/user/.config/chezmoi/chezmoi.toml -- sourceDir = "~/.local/share/chezmoi/home" diff --git a/internal/cmd/testdata/scripts/editconfig.txt b/internal/cmd/testdata/scripts/editconfig.txtar similarity index 88% rename from internal/cmd/testdata/scripts/editconfig.txt rename to internal/cmd/testdata/scripts/editconfig.txtar index 36576f91b78..c6433621db2 100644 --- a/internal/cmd/testdata/scripts/editconfig.txt +++ b/internal/cmd/testdata/scripts/editconfig.txtar @@ -1,22 +1,22 @@ # test that edit-config creates a config file if needed -chezmoi edit-config +exec chezmoi edit-config grep -count=1 '# edited' $CHEZMOICONFIGDIR/chezmoi.toml # test that edit-config edits an existing config file -chezmoi edit-config +exec chezmoi edit-config grep -count=2 '# edited' $CHEZMOICONFIGDIR/chezmoi.toml chhome home2/user # test that edit-config edits an existing YAML config file -chezmoi edit-config +exec chezmoi edit-config grep -count=1 '# edited' $CHEZMOICONFIGDIR/chezmoi.yaml ! stderr warning chhome home3/user # test that edit-config reports a warning if the config is no longer valid -chezmoi edit-config +exec chezmoi edit-config stderr warning ! stderr 'returned in less than' grep -count=1 '# edited' $CHEZMOICONFIGDIR/chezmoi.json diff --git a/internal/cmd/testdata/scripts/editconfigtemplate.txtar b/internal/cmd/testdata/scripts/editconfigtemplate.txtar new file mode 100644 index 00000000000..8e7429cbe08 --- /dev/null +++ b/internal/cmd/testdata/scripts/editconfigtemplate.txtar @@ -0,0 +1,40 @@ +[windows] unix2dos golden/edited +[windows] unix2dos golden/edited-chezmoi.yaml +[windows] unix2dos home3/user/.config/chezmoi/chezmoi.yaml +[windows] unix2dos home4/user/.local/share/chezmoi/home/.chezmoi.yaml.tmpl + +# test that chezmoi edit-config-template creates a new config file template +exec chezmoi edit-config-template +cmp $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl golden/edited + +chhome home2/user + +# test that chezmoi edit-config-template creates a new config file template in .chezmoiroot +exec chezmoi edit-config-template +cmp $CHEZMOISOURCEDIR/home/.chezmoi.toml.tmpl golden/edited + +chhome home3/user + +# test that chezmoi edit-config-template creates a new config file template from an existing config file +exec chezmoi edit-config-template +cmp $CHEZMOISOURCEDIR/.chezmoi.yaml.tmpl golden/edited-chezmoi.yaml + +chhome home4/user + +# test that chezmoi edit-config-template edits an existing config file template +exec chezmoi edit-config-template +cmp $CHEZMOISOURCEDIR/home/.chezmoi.yaml.tmpl golden/edited-chezmoi.yaml + +-- golden/edited -- +# edited +-- golden/edited-chezmoi.yaml -- +{} +# edited +-- home2/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home3/user/.config/chezmoi/chezmoi.yaml -- +{} +-- home4/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home4/user/.local/share/chezmoi/home/.chezmoi.yaml.tmpl -- +{} diff --git a/internal/cmd/testdata/scripts/edithardlink.txt b/internal/cmd/testdata/scripts/edithardlink.txtar similarity index 81% rename from internal/cmd/testdata/scripts/edithardlink.txt rename to internal/cmd/testdata/scripts/edithardlink.txtar index cc481ac1f1f..10762aeea3f 100644 --- a/internal/cmd/testdata/scripts/edithardlink.txt +++ b/internal/cmd/testdata/scripts/edithardlink.txtar @@ -1,11 +1,11 @@ [windows] skip 'Windows does not support hardlinks' # test that chezmoi edit uses a hardlink by default -chezmoi edit $HOME${/}.file +exec chezmoi edit $HOME${/}.file stdout /\.file$ # test that chezmoi edit --hardlink=false does not use a hardlink -chezmoi edit --hardlink=false $HOME${/}.file +exec chezmoi edit --hardlink=false $HOME${/}.file stdout ${CHEZMOISOURCEDIR@R}/dot_file$ -- home/user/.config/chezmoi/chezmoi.toml -- diff --git a/internal/cmd/testdata/scripts/ejson.txtar b/internal/cmd/testdata/scripts/ejson.txtar new file mode 100644 index 00000000000..096d638c1f5 --- /dev/null +++ b/internal/cmd/testdata/scripts/ejson.txtar @@ -0,0 +1,51 @@ +# test ejsonDecrypt uses default parameters +! exec chezmoi execute-template '{{ (ejsonDecrypt "golden/my-file.ejson") }}' +stderr 'couldn''t read key file' +stderr '/opt/ejson/keys/df82a403a3b58ebedd09758d3b131ff3113b39bdbfb92110940eb57832774345' + +# test ejsonDecrypt uses EJSON_KEYDIR when set +env EJSON_KEYDIR=golden/keys +exec chezmoi execute-template '{{ (ejsonDecrypt "golden/my-file.ejson").key1 }}' +stdout ^value1$ + +# test ejsonDecrypt uses configuration's keyDir when set +chhome home_set_valid_keydir/user +env EJSON_KEYDIR=invalid/keys +exec chezmoi execute-template '{{ (ejsonDecrypt "golden/my-file.ejson").key2 }}' +stdout ^value2$ + +# test ejsonDecrypt uses configuration's key when set, and succeeds if valid +chhome home_set_valid_key/user +exec chezmoi execute-template '{{ (ejsonDecrypt "golden/my-file.ejson").key1 }}' +stdout ^value1$ + +# test ejsonDecrypt uses configuration's key when set, and fails if invalid +chhome home_set_invalid_key/user +! exec chezmoi execute-template '{{ (ejsonDecrypt "golden/my-file.ejson") }}' + +# test ejsonDecryptWithKey uses the key passed as parameter, and succeeds if valid +chhome home_set_invalid_key/user +exec chezmoi execute-template '{{ (ejsonDecryptWithKey "golden/my-file.ejson" "4fed3b88a33a4621b30230f1ad17e175e10f8587e37e84da740711c9fecfe16d").key2 }}' +stdout ^value2$ + +# test ejsonDecryptWithKey uses the key passed as parameter, and fails if invalid +chhome home_set_valid_key/user +! exec chezmoi execute-template '{{ (ejsonDecryptWithKey "golden/my-file.ejson" "invalid") }}' + +-- golden/keys/df82a403a3b58ebedd09758d3b131ff3113b39bdbfb92110940eb57832774345 -- +4fed3b88a33a4621b30230f1ad17e175e10f8587e37e84da740711c9fecfe16d +-- golden/my-file.ejson -- +{ + "_public_key": "df82a403a3b58ebedd09758d3b131ff3113b39bdbfb92110940eb57832774345", + "key1": "EJ[1:t1Ql8sPo+fpQxHSxarJYDctfjXwfB9+OMH4BK/0CQEE=:9lajUfn0rbr/fbVYHi0yF/BH64htU4yF:8ydfFcJ7UO6rg7TGO2vqT19NBSk02Q==]", + "key2": "EJ[1:t1Ql8sPo+fpQxHSxarJYDctfjXwfB9+OMH4BK/0CQEE=:vmOdZjp4gqY0pmjeVb/BQQaFzW17wK5f:7UtzdtHcrwvwZdjqm4Jmn9GHxFkR1Q==]" +} +-- home_set_invalid_key/user/.config/chezmoi/chezmoi.yaml -- +ejson: + key: "foo" +-- home_set_valid_key/user/.config/chezmoi/chezmoi.yaml -- +ejson: + key: "4fed3b88a33a4621b30230f1ad17e175e10f8587e37e84da740711c9fecfe16d" +-- home_set_valid_keydir/user/.config/chezmoi/chezmoi.yaml -- +ejson: + keyDir: "golden/keys" diff --git a/internal/cmd/testdata/scripts/encryptiontemplatefuncs.txt b/internal/cmd/testdata/scripts/encryptiontemplatefuncs.txtar similarity index 56% rename from internal/cmd/testdata/scripts/encryptiontemplatefuncs.txt rename to internal/cmd/testdata/scripts/encryptiontemplatefuncs.txtar index a1e7034cca1..4ca51b0c886 100644 --- a/internal/cmd/testdata/scripts/encryptiontemplatefuncs.txt +++ b/internal/cmd/testdata/scripts/encryptiontemplatefuncs.txtar @@ -3,18 +3,18 @@ mkageconfig # test encrypt template function -chezmoi execute-template '{{ "plaintext" | encrypt }}' +exec chezmoi execute-template '{{ "plaintext" | encrypt }}' stdout '-----BEGIN AGE ENCRYPTED FILE-----' # test encrypt and decrypt template function round trip -chezmoi execute-template '{{ "plaintext\n" | encrypt | decrypt }}' +exec chezmoi execute-template '{{ "plaintext\n" | encrypt | decrypt }}' cmp stdout golden/plaintext [windows] stop 'remaining tests rely on UNIX path handling' # FIXME # test decrypt template function -chezmoi encrypt --output=$HOME${/}ciphertext.age golden/plaintext -chezmoi execute-template '{{ joinPath (env "HOME") "ciphertext.age" | include | decrypt }}' +exec chezmoi encrypt --output=$HOME${/}ciphertext.age golden/plaintext +exec chezmoi execute-template '{{ joinPath (env "HOME") "ciphertext.age" | include | decrypt }}' cmp stdout golden/plaintext -- golden/plaintext -- diff --git a/internal/cmd/testdata/scripts/errors.txt b/internal/cmd/testdata/scripts/errors.txt deleted file mode 100644 index 61163f915f2..00000000000 --- a/internal/cmd/testdata/scripts/errors.txt +++ /dev/null @@ -1,43 +0,0 @@ -mksourcedir - -# test duplicate source state entry detection -cp $CHEZMOISOURCEDIR/dot_file $CHEZMOISOURCEDIR/empty_dot_file -! chezmoi verify -stderr 'inconsistent state' - -chhome home2/user - -# test invalid config -! chezmoi verify -stderr 'invalid config' - -chhome home3/user - -# test source directory is not a directory -! chezmoi verify -stderr 'not a directory' - -chhome home4/user - -# test that chezmoi checks .chezmoiversion -! chezmoi verify -stderr 'source state requires version' - -chhome home5/user - -# test duplicate script detection -! chezmoi verify -stderr 'inconsistent state' - -# 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/internal/cmd/testdata/scripts/errors.txtar b/internal/cmd/testdata/scripts/errors.txtar new file mode 100644 index 00000000000..73099efe0c6 --- /dev/null +++ b/internal/cmd/testdata/scripts/errors.txtar @@ -0,0 +1,67 @@ +mksourcedir + +# test duplicate source state entry detection +cp $CHEZMOISOURCEDIR/dot_file $CHEZMOISOURCEDIR/empty_dot_file +! exec chezmoi verify +stderr 'inconsistent state' + +chhome home2/user + +# test invalid config +! exec chezmoi verify +stderr 'invalid config' + +chhome home3/user + +# test source directory is not a directory +! exec chezmoi verify +stderr 'not a directory' + +chhome home4/user + +# test that chezmoi checks .chezmoiversion +! exec chezmoi verify +stderr 'source state requires chezmoi version' + +chhome home5/user +# test that chezmoi checks .chezmoiversion when .chezmoiroot is used +! exec chezmoi verify +stderr 'source state requires chezmoi version' + +chhome home6/user + +# test duplicate script detection +! exec chezmoi verify +stderr 'inconsistent state' + +chhome home7/user + +# test that chezmoi init returns an error if there are multiple config file templates +! exec chezmoi init +stderr 'multiple config file templates' + +chhome home8/user + +# test that chezmoi verify returns an error if there are multiple config files +! exec chezmoi verify +stderr 'multiple config files' + +-- 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/.chezmoiroot -- +home +-- home5/user/.local/share/chezmoi/.chezmoiversion -- +3.0.0 +-- home6/user/.local/share/chezmoi/run_install_packages -- +# contents of install_packages +-- home6/user/.local/share/chezmoi/run_once_install_packages -- +# contents of install_packages +-- home7/user/.local/share/chezmoi/.chezmoi.json.tmpl -- +-- home7/user/.local/share/chezmoi/.chezmoi.toml.tmpl -- +-- home8/user/.config/chezmoi/chezmoi.json -- +-- home8/user/.config/chezmoi/chezmoi.toml -- +-- home8/user/.local/share/chezmoi/.keep -- diff --git a/internal/cmd/testdata/scripts/exclude.txt b/internal/cmd/testdata/scripts/exclude.txt deleted file mode 100644 index 8b5f088732d..00000000000 --- a/internal/cmd/testdata/scripts/exclude.txt +++ /dev/null @@ -1,74 +0,0 @@ -[!umask:022] skip - -# test chezmoi diff --exclude -chezmoi diff --exclude=scripts -[!windows] cmp stdout golden/diff-no-scripts -[windows] cmp stdout golden/diff-no-scripts-windows - -# test that chezmoi diff respects the diff.exclude configuration variable -chezmoi diff -[!windows] cmp stdout golden/diff -[windows] cmp stdout golden/diff-windows -mkdir $CHEZMOICONFIGDIR -cp golden/chezmoi.toml $CHEZMOICONFIGDIR -chezmoi diff -[!windows] cmp stdout golden/diff-no-scripts -[windows] cmp stdout golden/diff-no-scripts-windows - --- home/user/.local/share/chezmoi/dot_file -- -# contents of .file --- home/user/.local/share/chezmoi/run_script -- -#!/bin/sh - -echo $* --- golden/chezmoi.toml -- -[diff] - exclude = ["scripts"] --- golden/diff -- -diff --git a/.file b/.file -new file mode 100644 -index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 ---- /dev/null -+++ b/.file -@@ -0,0 +1 @@ -+# contents of .file -diff --git a/script b/script -index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..3747a7ba08ee591c41b7c518e430d2802137eac4 100755 ---- a/script -+++ b/script -@@ -0,0 +1,3 @@ -+#!/bin/sh -+ -+echo $* --- golden/diff-windows -- -diff --git a/.file b/.file -new file mode 100666 -index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 ---- /dev/null -+++ b/.file -@@ -0,0 +1 @@ -+# contents of .file -diff --git a/script b/script -index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..3747a7ba08ee591c41b7c518e430d2802137eac4 100755 ---- a/script -+++ b/script -@@ -0,0 +1,3 @@ -+#!/bin/sh -+ -+echo $* --- golden/diff-no-scripts -- -diff --git a/.file b/.file -new file mode 100644 -index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 ---- /dev/null -+++ b/.file -@@ -0,0 +1 @@ -+# contents of .file --- golden/diff-no-scripts-windows -- -diff --git a/.file b/.file -new file mode 100666 -index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 ---- /dev/null -+++ b/.file -@@ -0,0 +1 @@ -+# contents of .file diff --git a/internal/cmd/testdata/scripts/exclude.txtar b/internal/cmd/testdata/scripts/exclude.txtar new file mode 100644 index 00000000000..10c92de68bd --- /dev/null +++ b/internal/cmd/testdata/scripts/exclude.txtar @@ -0,0 +1,100 @@ +[!umask:022] skip + +# test chezmoi diff --exclude +exec chezmoi diff --exclude=scripts +[unix] cmp stdout golden/diff-no-scripts.diff +[windows] cmp stdout golden/diff-no-scripts-windows.diff + +# test that chezmoi diff respects the diff.exclude configuration variable +exec chezmoi diff +[unix] cmp stdout golden/diff.diff +[windows] cmp stdout golden/diff-windows.diff +mkdir $CHEZMOICONFIGDIR +cp golden/chezmoi.toml $CHEZMOICONFIGDIR +exec chezmoi diff +[unix] cmp stdout golden/diff-no-scripts.diff +[windows] cmp stdout golden/diff-no-scripts-windows.diff + +chhome home2/user + +# test that chezmoi diff --exclude=always excludes scripts that are always run +exec chezmoi diff --exclude=always +cmp stdout golden/diff-exclude-always.diff + +-- golden/chezmoi.toml -- +[diff] + exclude = ["scripts"] +-- golden/diff-exclude-always.diff -- +diff --git a/script-once.sh b/script-once.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..d7dff6100cefc930f4161600c12b6f7ea37b7d3a +--- /dev/null ++++ b/script-once.sh +@@ -0,0 +1,3 @@ ++#!/bin/sh ++ ++echo once +-- golden/diff-no-scripts-windows.diff -- +diff --git a/.file b/.file +new file mode 100666 +index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 +--- /dev/null ++++ b/.file +@@ -0,0 +1 @@ ++# contents of .file +-- golden/diff-no-scripts.diff -- +diff --git a/.file b/.file +new file mode 100644 +index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 +--- /dev/null ++++ b/.file +@@ -0,0 +1 @@ ++# contents of .file +-- golden/diff-windows.diff -- +diff --git a/.file b/.file +new file mode 100666 +index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 +--- /dev/null ++++ b/.file +@@ -0,0 +1 @@ ++# contents of .file +diff --git a/script.sh b/script.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..3747a7ba08ee591c41b7c518e430d2802137eac4 +--- /dev/null ++++ b/script.sh +@@ -0,0 +1,3 @@ ++#!/bin/sh ++ ++echo $* +-- golden/diff.diff -- +diff --git a/.file b/.file +new file mode 100644 +index 0000000000000000000000000000000000000000..8a52cb9ce9551221716a53786ad74104c5902362 +--- /dev/null ++++ b/.file +@@ -0,0 +1 @@ ++# contents of .file +diff --git a/script.sh b/script.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..3747a7ba08ee591c41b7c518e430d2802137eac4 +--- /dev/null ++++ b/script.sh +@@ -0,0 +1,3 @@ ++#!/bin/sh ++ ++echo $* +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file +-- home/user/.local/share/chezmoi/run_script.sh -- +#!/bin/sh + +echo $* +-- home2/user/.local/share/chezmoi/run_once_script-once.sh -- +#!/bin/sh + +echo once +-- home2/user/.local/share/chezmoi/run_script-always.sh -- +#!/bin/sh + +echo always diff --git a/internal/cmd/testdata/scripts/executetemplate.txt b/internal/cmd/testdata/scripts/executetemplate.txt deleted file mode 100644 index 5e909cebeab..00000000000 --- a/internal/cmd/testdata/scripts/executetemplate.txt +++ /dev/null @@ -1,100 +0,0 @@ -# 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 that chezmoi execute-template --init does not include .chezmoidata. data -! chezmoi execute-template --init '{{ .last.format }}' -stderr 'map has no entry for key "format"' - -# 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 --promptBool with a default value -chezmoi execute-template --init '{{ promptBool "value" true }}' -stdout true - -# 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 --promptInt with a default value -chezmoi execute-template --init '{{ promptInt "value" 1 }}' -stdout 1 - -# test --init --promptString -chezmoi execute-template --init --promptString email=user@example.com '{{ promptString "email" }}' -stdout 'user@example.com' - -# test --init --promptString without a default value -chezmoi execute-template --init '{{ promptString "value" }}' -stdout value - -# test --init --promptString with a default value -chezmoi execute-template --init '{{ promptString "value" "default" }}' -stdout default - --- 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/internal/cmd/testdata/scripts/executetemplate.txtar b/internal/cmd/testdata/scripts/executetemplate.txtar new file mode 100644 index 00000000000..afdff8b95e0 --- /dev/null +++ b/internal/cmd/testdata/scripts/executetemplate.txtar @@ -0,0 +1,148 @@ +# test reading args +exec chezmoi execute-template '{{ "arg-template" }}' +stdout arg-template + +# test reading from stdin +stdin golden/stdin.tmpl +exec chezmoi execute-template +stdout stdin-template + +# test reading from stdin with an argument +stdin golden/stdin +exec chezmoi execute-template --with-stdin '{{ .chezmoi.stdin | upper }}' +stdout '# CONTENTS OF STDIN' + +# test partial templates work +exec chezmoi execute-template '{{ template "partial" }}' +stdout 'hello world' + +# test that symlinks are supported in .chezmoitemplates +symlink $CHEZMOISOURCEDIR/.chezmoitemplates/symlink -> partial +exec chezmoi execute-template '{{ template "symlink" }}' +stdout 'hello world' + +# FIXME merge the following tests into a single test + +exec chezmoi execute-template '{{ .last.config }}' +stdout 'chezmoi\.toml' + +# test that template data are read from .chezmoidata.json +exec chezmoi execute-template '{{ .last.json }}' +stdout '\.chezmoidata\.json' + +# test that template data are read from .chezmoidata.toml +exec chezmoi execute-template '{{ .last.toml }}' +stdout '\.chezmoidata\.toml' + +# test that template data are read from .chezmoidata.yaml +exec chezmoi execute-template '{{ .last.yaml }}' +stdout '\.chezmoidata\.yaml' + +# test that the last .chezmoidata. file read wins +exec chezmoi execute-template '{{ .last.format }}' +stdout '\.chezmoidata\.yaml' + +# test that the config file wins over .chezmoidata. +exec chezmoi execute-template '{{ .last.global }}' +stdout chezmoi.toml + +# test that chezmoi execute-template --init does not include .chezmoidata. data +! exec chezmoi execute-template --init '{{ .last.format }}' +stderr 'map has no entry for key "format"' + +# test --init --promptBool +exec chezmoi execute-template --init --promptBool value=yes '{{ promptBool "value" }}' +stdout true +! exec chezmoi execute-template --promptBool value=error +stderr 'invalid syntax' + +# test --init --promptBool with a default value +exec chezmoi execute-template --init '{{ promptBool "value" true }}' +stdout true + +# test --init --promptChoice +exec chezmoi execute-template --init --promptChoice value=one '{{ promptChoice "value" (list "one" "two" "three") }}' +stdout one + +# test --init --promptChoice with an invalid value +! exec chezmoi execute-template --init --promptChoice value=four '{{ promptChoice "value" (list "one" "two" "three") }}' +stderr 'invalid choice' + +# test --init --promptChoice with a default value +exec chezmoi execute-template --init '{{ promptChoice "value" (list "one" "two" "three") "three" }}' +stdout three + +# test --init --promptInt +exec chezmoi execute-template --init --promptInt value=1 '{{ promptInt "value" }}' +stdout 1 +! exec chezmoi execute-template --promptInt value=error +stderr 'invalid syntax' + +# test --init --promptInt with a default value +exec chezmoi execute-template --init '{{ promptInt "value" 1 }}' +stdout 1 + +# test --init --promptString +exec chezmoi execute-template --init --promptString email=user@example.com '{{ promptString "email" }}' +stdout 'user@example.com' + +# test --init --promptString without a default value +exec chezmoi execute-template --init '{{ promptString "value" }}' +stdout value + +# test --init --promptString with a default value +exec chezmoi execute-template --init '{{ promptString "value" "default" }}' +stdout default + +# test that chezmoi execute-template reads all files in the .chezmoidata subdirectory +exec chezmoi execute-template '{{ .a }}{{ .b }}' +stdout 'alphabeta' + +chhome home2/user + +# test that files in .chezmoidata must have known extensions +! exec chezmoi execute-template +stderr 'unknown format' + +chhome home3/user + +# test that .chezmoiignore files are not allowed in .chezmoidata +! exec chezmoi execute-template +stderr 'not allowed in .chezmoidata directory' + +-- golden/stdin -- +# contents of stdin +-- 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/a.json -- +{"a":"alpha"} +-- home/user/.local/share/chezmoi/.chezmoidata/dir/b.yaml -- +b: beta +-- 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/.ignore -- +{{ "invalid template" +-- home/user/.local/share/chezmoi/.chezmoitemplates/partial -- +{{ cat "hello" "world" }} +-- home2/user/.local/share/chezmoi/.chezmoidata/unknown -- +-- home3/user/.local/share/chezmoi/.chezmoidata/.chezmoiignore -- diff --git a/internal/cmd/testdata/scripts/external.txt b/internal/cmd/testdata/scripts/external.txt deleted file mode 100644 index 6914919c05c..00000000000 --- a/internal/cmd/testdata/scripts/external.txt +++ /dev/null @@ -1,136 +0,0 @@ -symlink archive/dir/symlink -> file -exec tar czf www/archive.tar.gz archive - -httpd www - -# test that chezmoi reads external files from .chezmoiexternal.toml -chezmoi apply --force -cmp $HOME/.file golden/.file -[!windows] cmpmod 666 $HOME/.file - -chhome home2/user - -# test that chezmoi reads executable external files from .chezmoiexternal.toml -chezmoi apply --force -cmp $HOME/.file golden/.file -[!windows] cmpmod 777 $HOME/.file - -chhome home3/user - -# test that chezmoi reads external archives from .chezmoiexternal.yaml -chezmoi apply --force -cmp $HOME/.dir/dir/file golden/dir/file -[!windows] readlink $HOME/.dir/dir/symlink file -exists $HOME/.dir/file - -chhome home4/user - -# test that chezmoi reads exact external archives from .chezmoiexternal.yaml -chezmoi apply --force -cmp $HOME/.dir/dir/file golden/dir/file -[!windows] readlink $HOME/.dir/dir/symlink file -! exists $HOME/.dir/file - -chhome home5/user - -# test that chezmoi reads externals from subdirectories -chezmoi apply --force -cmp $HOME/.dir/subdir/dir/file golden/dir/file -[!windows] readlink $HOME/.dir/subdir/dir/symlink file - -chhome home6/user - -# test that .chezmoiignore applies to entries in externals -chezmoi apply --force -exists $HOME/.dir/dir/ -exists $HOME/.dir/dir/file -! exists $HOME/.dir/dir/symlink - -chhome home7/user - -# test that .chezmoiignore applies to entire externals -chezmoi apply --force - -chhome home8/user - -# test that parent directories are created if needed -chezmoi apply --force -cmp $HOME/.dir1/file golden/dir/file -cmp $HOME/.dir2/dir2/file golden/dir/file -cmp $HOME/.dir3/dir3/dir3/file golden/dir/file - -chhome home9/user - -# test that duplicate equivalent directories are allowed -chezmoi apply --force - --- archive/dir/file -- -# contents of dir/file --- golden/.file -- -# contents of .file --- golden/dir/file -- -# contents of dir/file --- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- -[".file"] - type = "file" - url = "{{ env "HTTPD_URL" }}/.file" --- home2/user/.local/share/chezmoi/.chezmoiexternal.toml -- -[".file"] - type = "file" - url = "{{ env "HTTPD_URL" }}/.file" - executable = true --- home3/user/.dir/file -- --- home3/user/.local/share/chezmoi/.chezmoiexternal.yaml -- -.dir: - type: archive - url: {{ env "HTTPD_URL" }}/archive.tar.gz - stripComponents: 1 --- home4/user/.dir/file -- --- home4/user/.local/share/chezmoi/.chezmoiexternal.yaml -- -.dir: - type: archive - url: {{ env "HTTPD_URL" }}/archive.tar.gz - exact: true - stripComponents: 1 --- home5/user/.local/share/chezmoi/dot_dir/.chezmoiexternal.yaml -- -subdir: - type: archive - url: {{ env "HTTPD_URL" }}/archive.tar.gz - exact: true - stripComponents: 1 --- home6/user/.local/share/chezmoi/.chezmoiexternal.yaml -- -.dir: - type: archive - url: {{ env "HTTPD_URL" }}/archive.tar.gz - exact: true - stripComponents: 1 --- home6/user/.local/share/chezmoi/.chezmoiignore -- -.dir/dir/symlink --- home7/user/.local/share/chezmoi/.chezmoiexternal.yaml -- -.dir: - type: archive - url: {{ env "HTTPD_URL" }}/non-existent-archive.tar.gz --- home7/user/.local/share/chezmoi/.chezmoiignore -- -.dir --- home8/user/.local/share/chezmoi/.chezmoiexternal.toml -- -[".dir1"] - type = "archive" - url = "{{ env "HTTPD_URL" }}/archive.tar.gz" - stripComponents = 2 -[".dir2/dir2"] - type = "archive" - url = "{{ env "HTTPD_URL" }}/archive.tar.gz" - stripComponents = 2 -[".dir3/dir3/dir3"] - type = "archive" - url = "{{ env "HTTPD_URL" }}/archive.tar.gz" - stripComponents = 2 --- home9/user/.local/share/chezmoi/.chezmoiexternal.toml -- -[".dir"] - type = "archive" - url = "{{ env "HTTPD_URL" }}/archive.tar.gz" - stripComponents = 1 --- home9/user/.local/share/chezmoi/dot_dir/file2 -- -# contents of .dir/file2 --- www/.file -- -# contents of .file diff --git a/internal/cmd/testdata/scripts/external.txtar b/internal/cmd/testdata/scripts/external.txtar new file mode 100644 index 00000000000..ece0b9a08ec --- /dev/null +++ b/internal/cmd/testdata/scripts/external.txtar @@ -0,0 +1,269 @@ +symlink archive/dir/symlink -> file +[darwin] exec xattr -w -s io.chezmoi.test metadata-test archive/dir archive/dir/symlink archive/dir/file +exec tar czf www/archive.tar.gz archive +# Force the collection of the Mac metadata for the home15/user test +[darwin] exec tar czf www/archive-mac-metadata.tar.gz --mac-metadata archive + +httpd www + +# test that chezmoi diff includes external files by default +exec chezmoi diff +stdout '^diff --git a/\.file b/\.file$' + +# test that chezmoi diff --exclude=externals excludes diffs from external files +exec chezmoi diff --exclude=externals +! stdout '^diff --git a/\.file b/\.file$' + +# test that chezmoi reads external files from .chezmoiexternal.toml +exec chezmoi apply --force +cmp $HOME/.file golden/.file +[unix] cmpmod 666 $HOME/.file + +chhome home2/user + +# test that chezmoi reads executable external files from .chezmoiexternal.toml +exec chezmoi apply --force +cmp $HOME/.file golden/.file +[unix] cmpmod 777 $HOME/.file + +chhome home3/user + +# test that chezmoi managed --include=externals lists external targets +exec chezmoi managed --include=externals +cmp stdout golden/managed + +# test that chezmoi diff includes external archives by default +exec chezmoi diff +stdout '^diff --git a/\.dir/dir/file b/\.dir/dir/file$' + +# test that chezmoi diff --exclude=externals excludes diffs from external archives +exec chezmoi diff --exclude=externals +! stdout '^diff --git a/\.dir/dir/file b/\.dir/dir/file$' + +# test that chezmoi reads external archives from .chezmoiexternal.yaml +exec chezmoi apply --force +cmp $HOME/.dir/dir/file golden/dir/file +[unix] readlink $HOME/.dir/dir/symlink file +exists $HOME/.dir/file + +chhome home4/user + +# test that chezmoi reads exact external archives from .chezmoiexternal.yaml +exec chezmoi apply --force +cmp $HOME/.dir/dir/file golden/dir/file +[unix] readlink $HOME/.dir/dir/symlink file +! exists $HOME/.dir/file + +chhome home5/user + +# test that chezmoi reads externals from subdirectories +exec chezmoi apply --force +cmp $HOME/.dir/subdir/dir/file golden/dir/file +[unix] readlink $HOME/.dir/subdir/dir/symlink file + +chhome home6/user + +# test that .chezmoiignore applies to entries in externals +exec chezmoi apply --force +exists $HOME/.dir/dir/ +exists $HOME/.dir/dir/file +! exists $HOME/.dir/dir/symlink + +chhome home7/user + +# test that .chezmoiignore applies to entire externals +exec chezmoi apply --force + +chhome home8/user + +# test that parent directories are created if needed +exec chezmoi apply --force +cmp $HOME/.dir1/file golden/dir/file +cmp $HOME/.dir2/dir2/file golden/dir/file +cmp $HOME/.dir3/dir3/dir3/file golden/dir/file + +chhome home9/user + +# test that duplicate equivalent directories are allowed +exec chezmoi apply --force + +chhome home10/user + +# test that checksums are verified +exec chezmoi apply --force +cp $HOME/.file golden/.file + +chhome home11/user + +# test that checksums detect corrupt files +! exec chezmoi apply --force +stderr \.file:\sMD5\smismatch +stderr 'SHA256 mismatch' + +chhome home12/user + +# test that chezmoi reads archive-file externals +exec chezmoi apply +cmp $HOME/.file golden/dir/file + +chhome home13/user + +# test that chezmoi can set executable bits on archive-file externals +exec chezmoi apply +[umask:002] cmpmod 775 $HOME/.file +[umask:022] cmpmod 755 $HOME/.file + +chhome home14/user + +# test that chezmoi reads external files from the .chezmoiexternal directory +exec chezmoi apply +cmp $HOME/.file golden/dir/file + +[darwin] chhome home15/user + +# test that chezmoi managed --include=externals lists external targets including AppleDouble files +[darwin] exec chezmoi managed --include=externals +[darwin] cmp stdout golden/managed-appledouble + +chhome home16/user + +# test that readonly and private attributes are set +[!windows] exec chezmoi apply +[!windows] cmpmod 400 $HOME/.file + +-- archive/dir/file -- +# contents of dir/file +-- golden/.file -- +# contents of .file +-- golden/dir/file -- +# contents of dir/file +-- golden/managed -- +.dir +.dir/dir +.dir/dir/file +.dir/dir/symlink +-- golden/managed-appledouble -- +.dir +.dir/._dir +.dir/dir +.dir/dir/._file +.dir/dir/._symlink +.dir/dir/file +.dir/dir/symlink +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".file"] + type = "file" + url = "{{ env "HTTPD_URL" }}/.file" +-- home10/user/.local/share/chezmoi/.chezmoiexternal.yaml -- +.file: + type: file + url: {{ env "HTTPD_URL" }}/.file + checksum: + size: 20 + md5: 49fe9018f97349cdd0a0ac7b7f668b05 + ripemd160: 2320636f6e74656e7473206f66202e66696c650a9c1185a5c5e9fc54612808977ee8f548b2258d31 + sha1: cb91d72dc73f6d984b33ac5745f1cf6f76745bd2 + sha256: 634a4dd193c7b3b926d2e08026aa81a416fd41cec52854863b974af422495663 + sha384: f8545bb66433eb514727bbc61c4e4939c436d38079767f39f12b8803d6472ca1dfcd101675b20cd525f7e3d02c368b61 + sha512: a68814ec3d16e8bd28c9291bbc596f0282687c5ba5d1f4c26c4e427166666a03c11df1dab3577b4483142764c37d4887def77244c4a52cb9852a234fa8cb15ba +-- home11/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".file"] + type = "file" + url = "{{ env "HTTPD_URL" }}/.corrupt-file" + checksum.md5 = "49fe9018f97349cdd0a0ac7b7f668b05" + checksum.sha256 = "634a4dd193c7b3b926d2e08026aa81a416fd41cec52854863b974af422495663" +-- home12/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".file"] + type = "archive-file" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + path = "dir/file" + stripComponents = 1 +-- home13/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".file"] + type = "archive-file" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + path = "dir/file" + executable = true + stripComponents = 1 +-- home14/user/.local/share/chezmoi/.chezmoiexternals/external.toml -- +[".file"] + type = "archive-file" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + path = "dir/file" + stripComponents = 1 +-- home15/user/.dir/file -- +-- home15/user/.local/share/chezmoi/.chezmoiexternal.yaml -- +.dir: + type: archive + url: {{ env "HTTPD_URL" }}/archive-mac-metadata.tar.gz + archive: + extractAppleDoubleFiles: true + stripComponents: 1 +-- home16/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".file"] + type = "file" + url = "{{ env "HTTPD_URL" }}/.file" + private = true + readonly = true +-- home2/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".file"] + type = "file" + url = "{{ env "HTTPD_URL" }}/.file" + executable = true +-- home3/user/.dir/file -- +-- home3/user/.local/share/chezmoi/.chezmoiexternal.yaml -- +.dir: + type: archive + url: {{ env "HTTPD_URL" }}/archive.tar.gz + stripComponents: 1 +-- home4/user/.dir/file -- +-- home4/user/.local/share/chezmoi/.chezmoiexternal.yaml -- +.dir: + type: archive + url: {{ env "HTTPD_URL" }}/archive.tar.gz + exact: true + stripComponents: 1 +-- home5/user/.local/share/chezmoi/dot_dir/.chezmoiexternal.yaml -- +subdir: + type: archive + url: {{ env "HTTPD_URL" }}/archive.tar.gz + exact: true + stripComponents: 1 +-- home6/user/.local/share/chezmoi/.chezmoiexternal.yaml -- +.dir: + type: archive + url: {{ env "HTTPD_URL" }}/archive.tar.gz + exact: true + stripComponents: 1 +-- home6/user/.local/share/chezmoi/.chezmoiignore -- +.dir/dir/symlink +-- home7/user/.local/share/chezmoi/.chezmoiexternal.yaml -- +.dir: + type: archive + url: {{ env "HTTPD_URL" }}/non-existent-archive.tar.gz +-- home7/user/.local/share/chezmoi/.chezmoiignore -- +.dir +-- home8/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir1"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + stripComponents = 2 +[".dir2/dir2"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + stripComponents = 2 +[".dir3/dir3/dir3"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + stripComponents = 2 +-- home9/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + stripComponents = 1 +-- home9/user/.local/share/chezmoi/dot_dir/file2 -- +# contents of .dir/file2 +-- www/.corrupt-file -- +# corrupt contents of .file +-- www/.file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/externalarchiveinclude.txtar b/internal/cmd/testdata/scripts/externalarchiveinclude.txtar new file mode 100644 index 00000000000..6e9bdda955e --- /dev/null +++ b/internal/cmd/testdata/scripts/externalarchiveinclude.txtar @@ -0,0 +1,117 @@ +symlink archive/symlink1 -> file1 +symlink archive/symlink2 -> file2 +mkdir www +exec tar czf www/archive.tar.gz archive + +httpd www + +# test that chezmoi includes all files by default +exec chezmoi managed +cmp stdout golden/managed + +chhome home2/user + +# test that chezmoi can include only certain files by default +exec chezmoi managed +cmp stdout golden/managed2 + +chhome home3/user + +# test that chezmoi can exclude only certain files by default +exec chezmoi managed +cmp stdout golden/managed3 + +chhome home4/user + +# test that chezmoi can include and exclude files +exec chezmoi managed +cmp stdout golden/managed4 + +chhome home5/user + +# test that chezmoi can include selected files in sub-directories +exec chezmoi managed +cmp stdout golden/managed5 + +-- archive/dir/subdir1/file1 -- +# contents of dir/subdir1/file1 +-- archive/dir/subdir1/file2 -- +# contents of dir/subdir1/file2 +-- archive/dir/subdir2/file1 -- +# contents of dir/subdir2/file1 +-- archive/dir/subdir2/file2 -- +# contents of dir/subdir2/file2 +-- archive/file1 -- +# contents of file1 +-- archive/file2 -- +# contents of file2 +-- golden/managed -- +.dir +.dir/dir +.dir/dir/subdir1 +.dir/dir/subdir1/file1 +.dir/dir/subdir1/file2 +.dir/dir/subdir2 +.dir/dir/subdir2/file1 +.dir/dir/subdir2/file2 +.dir/file1 +.dir/file2 +.dir/symlink1 +.dir/symlink2 +-- golden/managed2 -- +.dir +.dir/dir +.dir/dir/subdir1 +.dir/dir/subdir1/file1 +.dir/dir/subdir1/file2 +-- golden/managed3 -- +.dir +.dir/dir +.dir/dir/subdir1 +.dir/dir/subdir1/file1 +.dir/file1 +.dir/symlink1 +-- golden/managed4 -- +.dir +.dir/dir +.dir/dir/subdir1 +.dir/dir/subdir1/file1 +.dir/dir/subdir2 +.dir/dir/subdir2/file1 +.dir/file1 +.dir/symlink1 +.dir/symlink2 +-- golden/managed5 -- +.dir +.dir/dir/subdir2/file1 +.dir/dir/subdir2/file2 +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + stripComponents = 1 +-- home2/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + stripComponents = 1 + include = ["*", "*/dir", "*/dir/subdir1", "*/dir/subdir1/**"] +-- home3/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + stripComponents = 1 + exclude = ["**/*2"] +-- home4/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + stripComponents = 1 + include = ["*/dir", "*/dir/**"] + exclude = ["**/file2"] +-- home5/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + stripComponents = 1 + include = ["*/dir/subdir2/*"] diff --git a/internal/cmd/testdata/scripts/externalcompression.txtar b/internal/cmd/testdata/scripts/externalcompression.txtar new file mode 100644 index 00000000000..2ac9b3b5107 --- /dev/null +++ b/internal/cmd/testdata/scripts/externalcompression.txtar @@ -0,0 +1,55 @@ +[exec:bzip2] exec bzip2 www/file-bzip2 +[exec:gzip] exec gzip www/file-gzip +[exec:xz] exec xz www/file-xz +[exec:zstd] exec zstd www/file-zstd + +httpd www + +# test that chezmoi apply decompresses files in multiple formats +exec chezmoi apply +[exec:bzip2] cmp $HOME/file-bzip2 golden/file-bzip2 +[exec:gzip] cmp $HOME/file-gzip golden/file-gzip +[exec:xz] cmp $HOME/file-xz golden/file-xz +[exec:zstd] cmp $HOME/file-zstd golden/file-zstd + +-- golden/file-bzip2 -- +# contents of file-bzip2 +-- golden/file-gzip -- +# contents of file-gzip +-- golden/file-xz -- +# contents of file-xz +-- golden/file-zstd -- +# contents of file-zstd +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml.tmpl -- +{{ if lookPath "bzip2" }} +[file-bzip2] + type = "file" + url = "{{ env "HTTPD_URL" }}/file-bzip2.bz2" + decompress = "bzip2" +{{ end }} +{{ if lookPath "gzip" }} +[file-gzip] + type = "file" + url = "{{ env "HTTPD_URL" }}/file-gzip.gz" + decompress = "gzip" +{{ end }} +{{ if lookPath "xz" }} +[file-xz] + type = "file" + url = "{{ env "HTTPD_URL" }}/file-xz.xz" + decompress = "xz" +{{ end }} +{{ if lookPath "zstd" }} +[file-zstd] + type = "file" + url = "{{ env "HTTPD_URL" }}/file-zstd.zst" + decompress = "zstd" +{{ end }} +-- www/file-bzip2 -- +# contents of file-bzip2 +-- www/file-gzip -- +# contents of file-gzip +-- www/file-xz -- +# contents of file-xz +-- www/file-zstd -- +# contents of file-zstd diff --git a/internal/cmd/testdata/scripts/externaldiff.txtar b/internal/cmd/testdata/scripts/externaldiff.txtar new file mode 100644 index 00000000000..fe6b83163f7 --- /dev/null +++ b/internal/cmd/testdata/scripts/externaldiff.txtar @@ -0,0 +1,36 @@ +[windows] skip 'UNIX only' + +# test that chezmoi diff invokes the external diff command for scripts +exec chezmoi diff +stdout '# contents of script' + +# test that chezmoi diff excludes scripts if configured +appendline ${CHEZMOICONFIGDIR}/chezmoi.toml ' exclude = ["scripts"]' +exec chezmoi diff +! stdout '# contents of script' + +chhome home2/user + +# test that chezmoi diff does not pass non-existent filenames to external diff command +exec chezmoi diff + +chhome home3/user + +# test that chezmoi diff passes /dev/null and directory names when creating directories +exec chezmoi diff +stdout ^/dev/null\s${WORK@R}/.*/\.dir$ + +-- home/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "cat" +-- home/user/.local/share/chezmoi/run_script -- +# contents of script +-- home2/user/.config/chezmoi/chezmoi.yaml -- +diff: + command: cat +-- home2/user/.local/share/chezmoi/dot_file -- +# contents of .file +-- home3/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "echo" +-- home3/user/.local/share/chezmoi/dot_dir/.keep -- diff --git a/internal/cmd/testdata/scripts/externaldir.txtar b/internal/cmd/testdata/scripts/externaldir.txtar new file mode 100644 index 00000000000..0ea47108379 --- /dev/null +++ b/internal/cmd/testdata/scripts/externaldir.txtar @@ -0,0 +1,35 @@ +symlink $CHEZMOISOURCEDIR/dot_vim/external_bundle/symlink -> executable_file + +# test that chezmoi ignores attributes in external_ dirs +exec chezmoi apply + +# test that executable_ attributes are ignored and empty files are created +cmp $HOME/.vim/bundle/executable_file $CHEZMOISOURCEDIR/dot_vim/external_bundle/executable_file + +# test that scripts are not run +cmp $HOME/.vim/bundle/run_script.sh $CHEZMOISOURCEDIR/dot_vim/external_bundle/run_script.sh +! stdout evil + +# test that private_ attributes are ignored and subdirectories are created +isdir $HOME/.vim/bundle/private_subdir + +# test that files can be ignored +! exists $HOME/.vim/bundle/private_subdir/.keep + +# test that symlinks are created +issymlink $HOME/.vim/bundle/symlink + +# test that symlink_ attributes are ignored +cmp $HOME/.vim/bundle/symlink_example $CHEZMOISOURCEDIR/dot_vim/external_bundle/symlink_example +! issymlink $HOME/.vim/bundle/symlink_example + +-- home/user/.local/share/chezmoi/.chezmoiignore -- +**/.keep +-- home/user/.local/share/chezmoi/dot_vim/external_bundle/executable_file -- +-- home/user/.local/share/chezmoi/dot_vim/external_bundle/private_subdir/.keep -- +-- home/user/.local/share/chezmoi/dot_vim/external_bundle/run_script.sh -- +#!/bin/sh + +echo evil +-- home/user/.local/share/chezmoi/dot_vim/external_bundle/symlink_example -- +# contents of .vim/bundle/symlink_example diff --git a/internal/cmd/testdata/scripts/externalencrypted.txt b/internal/cmd/testdata/scripts/externalencrypted.txtar similarity index 88% rename from internal/cmd/testdata/scripts/externalencrypted.txt rename to internal/cmd/testdata/scripts/externalencrypted.txtar index 26b565215db..031cab74a2d 100644 --- a/internal/cmd/testdata/scripts/externalencrypted.txt +++ b/internal/cmd/testdata/scripts/externalencrypted.txtar @@ -1,10 +1,11 @@ +[windows] skip 'skipping gpg tests on Windows' [!exec:gpg] skip 'gpg not found in $PATH' mkgpgconfig # use chezmoi's encryption to encrypt a file and an archive exec tar czf $HOME/archive.tar.gz archive -chezmoi add --encrypt $HOME${/}.file $HOME${/}archive.tar.gz +exec chezmoi add --encrypt $HOME${/}.file $HOME${/}archive.tar.gz mkdir www cp $CHEZMOISOURCEDIR/encrypted_dot_file.asc www/.file.asc cp $CHEZMOISOURCEDIR/encrypted_archive.tar.gz.asc www/archive.tar.gz.asc @@ -16,7 +17,7 @@ chhome home2/user # test that chezmoi reads encrypted external files and archives mkdir $CHEZMOICONFIGDIR cp home/user/.config/chezmoi/chezmoi.toml $CHEZMOICONFIGDIR -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.file golden/.file cmp $HOME/.dir/file golden/dir/file diff --git a/internal/cmd/testdata/scripts/externalfilter.txt b/internal/cmd/testdata/scripts/externalfilter.txtar similarity index 92% rename from internal/cmd/testdata/scripts/externalfilter.txt rename to internal/cmd/testdata/scripts/externalfilter.txtar index 1f6eb70081d..87db0bb63a8 100644 --- a/internal/cmd/testdata/scripts/externalfilter.txt +++ b/internal/cmd/testdata/scripts/externalfilter.txtar @@ -3,7 +3,7 @@ httpd www # test that chezmoi filters external files -chezmoi cat $HOME${/}.file +exec chezmoi cat $HOME${/}.file cmp stdout golden/.file -- golden/.file -- diff --git a/internal/cmd/testdata/scripts/externalgitrepo.txtar b/internal/cmd/testdata/scripts/externalgitrepo.txtar new file mode 100644 index 00000000000..7fc70a03253 --- /dev/null +++ b/internal/cmd/testdata/scripts/externalgitrepo.txtar @@ -0,0 +1,74 @@ +[windows] skip 'UNIX only' +[!exec:git] skip 'git not found in $PATH' + +mkgitconfig +expandenv $WORK/home/user/.local/share/chezmoi/.chezmoiexternal.toml + +# test that chezmoi managed lists the directory +exec chezmoi managed +stdout ^\.dir$ + +# create a git repo +cd $WORK/repo +exec git init +exec git add . +exec git commit --message 'initial commit' +cd $WORK + +# test that chezmoi apply clones the git repo +exec chezmoi apply +cmp $HOME/.dir/.file golden/.file + +# test that chezmoi archive --format=tar creates an archive +exec chezmoi archive --format=tar + +# test that chezmoi archive --format=zip creates an archive +exec chezmoi archive --format=zip + +chhome home2/user +mkgitconfig +expandenv $WORK/home2/user/.local/share/chezmoi/.chezmoiexternal.toml + +# test that chezmoi apply clones the git repo +exec chezmoi apply +cmp $HOME/.dir/.file golden/.file + +# update the git repo +cd $WORK/repo +edit $WORK/repo/.file +exec git commit --message 'edit .file' . +cd $WORK + +chhome home/user + +# test that chezmoi apply does not pull from the git repo when refreshPeriod is zero +exec chezmoi apply +! grep '# edited' $HOME/.dir/.file + +# test that chezmoi apply --refresh-externals does pull from the git repo +exec chezmoi apply --refresh-externals +grep '# edited' $HOME/.dir/.file + +chhome home2/user + +# test that chezmoi apply does not pull from the git repo within the refresh period +exec chezmoi apply +! grep '# edited' $HOME/.dir/.file + +# test that chezmoi dump prints the git command +exec chezmoi dump --format=yaml +stdout 'type: command' + +-- golden/.file -- +# contents of .file +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "git-repo" + url = "file://$WORK/repo" +-- home2/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "git-repo" + url = "file://$WORK/repo" + refreshPeriod = "1h" +-- repo/.file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/externalguess.txt b/internal/cmd/testdata/scripts/externalguess.txtar similarity index 85% rename from internal/cmd/testdata/scripts/externalguess.txt rename to internal/cmd/testdata/scripts/externalguess.txtar index 6c73a4e9725..f33e9e4b802 100644 --- a/internal/cmd/testdata/scripts/externalguess.txt +++ b/internal/cmd/testdata/scripts/externalguess.txtar @@ -5,19 +5,19 @@ httpd www # test that chezmoi sniffs the format of tar files exec tar -cf www/archive.tar archive/ cp www/archive.tar www/archive -chezmoi apply --force --refresh-externals +exec chezmoi apply --force --refresh-externals cmp $HOME/.dir/dir/file golden/dir/file # test that chezmoi sniffs the format of tar.bz2 files exec tar -cjf www/archive.tar.bz2 archive/ cp www/archive.tar.bz2 www/archive -chezmoi apply --force --refresh-externals +exec chezmoi apply --force --refresh-externals cmp $HOME/.dir/dir/file golden/dir/file # test that chezmoi sniffs the format of tar.gz files exec tar -czf www/archive.tar.gz archive/ cp www/archive.tar.gz www/archive -chezmoi apply --force --refresh-externals +exec chezmoi apply --force --refresh-externals cmp $HOME/.dir/dir/file golden/dir/file [!exec:zip] stop 'zip not found in $PATH' @@ -25,14 +25,14 @@ cmp $HOME/.dir/dir/file golden/dir/file # test that chezmoi sniffs the format of zip files exec zip -r www/archive.zip archive cp www/archive.zip www/archive -chezmoi apply --force --refresh-externals +exec chezmoi apply --force --refresh-externals cmp $HOME/.dir/dir/file golden/dir/file chhome home2/user # test that chezmoi allows the format to be overridden cp www/archive.zip www/archive2.tar.gz -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.dir/dir/file golden/dir/file -- archive/dir/file -- diff --git a/internal/cmd/testdata/scripts/externalzip.txt b/internal/cmd/testdata/scripts/externalzip.txtar similarity index 87% rename from internal/cmd/testdata/scripts/externalzip.txt rename to internal/cmd/testdata/scripts/externalzip.txtar index cfd4e0fae50..b300a638883 100644 --- a/internal/cmd/testdata/scripts/externalzip.txt +++ b/internal/cmd/testdata/scripts/externalzip.txtar @@ -1,3 +1,4 @@ +[windows] skip 'zip may not support the --symlinks option' [!exec:zip] skip 'zip not found in $PATH' symlink archive/dir/symlink -> file exec zip -r --symlinks www/archive.zip archive @@ -5,7 +6,7 @@ exec zip -r --symlinks www/archive.zip archive httpd www # test that chezmoi reads external zip archives from .chezmoiexternal.json -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.dir/dir/file golden/dir/file issymlink $HOME/.dir/dir/symlink diff --git a/internal/cmd/testdata/scripts/forget.txt b/internal/cmd/testdata/scripts/forget.txt deleted file mode 100644 index 86217df65ef..00000000000 --- a/internal/cmd/testdata/scripts/forget.txt +++ /dev/null @@ -1,36 +0,0 @@ -[windows] skip # FIXME make this test pass on windows - -mksourcedir - -# test that chezmoi apply sets the state -chezmoi apply --force -exists $CHEZMOISOURCEDIR/dot_file -chezmoi state get --bucket=entryState --key=$WORK/home/user/.dir -cmp stdout golden/state-get-dir.json -chezmoi state get --bucket=entryState --key=$WORK/home/user/.file -cmp stdout golden/state-get-file.json - -# test that chezmoi forget forgets a dir -exists $CHEZMOISOURCEDIR/dot_dir -chezmoi forget --force $HOME${/}.dir -! exists $CHEZMOISOURCEDIR/dot_dir -chezmoi state get --bucket=entryState --key=$WORK/home/user/.dir -! stdout . - -# test that chezmoi forget forgets a file -chezmoi forget --force $HOME${/}.file -! exists $CHEZMOISOURCEDIR/dot_file -chezmoi state get --bucket=entryState --key=$WORK/home/user/.file -! stdout . - --- golden/state-get-dir.json -- -{ - "type": "dir", - "mode": 2147484141 -} --- golden/state-get-file.json -- -{ - "type": "file", - "mode": 420, - "contentsSHA256": "634a4dd193c7b3b926d2e08026aa81a416fd41cec52854863b974af422495663" -} diff --git a/internal/cmd/testdata/scripts/forget.txtar b/internal/cmd/testdata/scripts/forget.txtar new file mode 100644 index 00000000000..44270fb51cd --- /dev/null +++ b/internal/cmd/testdata/scripts/forget.txtar @@ -0,0 +1,57 @@ +mksourcedir + +# test that chezmoi apply sets the state +exec chezmoi apply --force +exists $CHEZMOISOURCEDIR/dot_file +exec chezmoi state get --bucket=entryState --key=$WORK/home/user/.dir +[umask:002] cmp stdout golden/state-get-dir-umask-002.json +[umask:022] cmp stdout golden/state-get-dir-umask-022.json +exec chezmoi state get --bucket=entryState --key=$WORK/home/user/.file +[umask:002] cmp stdout golden/state-get-file-umask-002.json +[umask:022] cmp stdout golden/state-get-file-umask-022.json + +# test that chezmoi forget forgets a dir +exists $CHEZMOISOURCEDIR/dot_dir +exec chezmoi forget --force $HOME${/}.dir +! exists $CHEZMOISOURCEDIR/dot_dir +exec chezmoi state get --bucket=entryState --key=$WORK/home/user/.dir +! stdout . + +# test that chezmoi forget forgets a file +exec chezmoi forget --force $HOME${/}.file +! exists $CHEZMOISOURCEDIR/dot_file +exec chezmoi state get --bucket=entryState --key=$WORK/home/user/.file +! stdout . + +chhome home2/user + +# test that chezmoi forget forgets a file when .chezmoiroot is used +exec chezmoi forget --force $HOME${/}.file +! exists $CHEZMOISOURCEDIR/home/dot_file + +-- golden/state-get-dir-umask-002.json -- +{ + "type": "dir", + "mode": 2147484157 +} +-- golden/state-get-dir-umask-022.json -- +{ + "type": "dir", + "mode": 2147484141 +} +-- golden/state-get-file-umask-002.json -- +{ + "type": "file", + "mode": 436, + "contentsSHA256": "634a4dd193c7b3b926d2e08026aa81a416fd41cec52854863b974af422495663" +} +-- golden/state-get-file-umask-022.json -- +{ + "type": "file", + "mode": 420, + "contentsSHA256": "634a4dd193c7b3b926d2e08026aa81a416fd41cec52854863b974af422495663" +} +-- home2/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home2/user/.local/share/chezmoi/home/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/generate.txtar b/internal/cmd/testdata/scripts/generate.txtar new file mode 100644 index 00000000000..dc929bfa643 --- /dev/null +++ b/internal/cmd/testdata/scripts/generate.txtar @@ -0,0 +1,16 @@ +mkgitconfig + +# test that chezmoi generate install.sh generates a shell script +exec chezmoi generate install.sh +stdout '#!/bin/sh' + +[!exec:git] skip 'git not found in $PATH' + +# test that chezmoi generate git-commit-message generates a git commit message +exec git --git-dir=$CHEZMOISOURCEDIR/.git init +exec chezmoi git add . +exec chezmoi generate git-commit-message +stdout '^Add .file$' + +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/git.txt b/internal/cmd/testdata/scripts/git.txtar similarity index 79% rename from internal/cmd/testdata/scripts/git.txt rename to internal/cmd/testdata/scripts/git.txtar index 47bc8604597..786426e72b4 100644 --- a/internal/cmd/testdata/scripts/git.txt +++ b/internal/cmd/testdata/scripts/git.txtar @@ -1,7 +1,7 @@ -[!windows] chmod 755 bin/git +[unix] chmod 755 bin/git [windows] unix2dos bin/git.cmd -chezmoi git hello +exec chezmoi git hello exists $CHEZMOISOURCEDIR stdout hello diff --git a/internal/cmd/testdata/scripts/githubtemplatefuncs.txtar b/internal/cmd/testdata/scripts/githubtemplatefuncs.txtar new file mode 100644 index 00000000000..933a15f7343 --- /dev/null +++ b/internal/cmd/testdata/scripts/githubtemplatefuncs.txtar @@ -0,0 +1,24 @@ +[!env:CHEZMOI_GITHUB_TOKEN] skip '$CHEZMOI_GITHUB_TOKEN not set' + +# test gitHubKeys template function +exec chezmoi execute-template '{{ (index (gitHubKeys "twpayne") 0).Key }}' +stdout ^ssh-rsa + +# test gitHubLatestRelease template function +exec chezmoi execute-template '{{ (gitHubLatestRelease "twpayne/chezmoi").TagName }}' +stdout ^v2\. + +# test gitHubLatestTag template function +exec chezmoi execute-template '{{ (gitHubLatestTag "twpayne/chezmoi").Name }}' +stdout ^v2\. + +# test gitHubTags template functions +exec chezmoi execute-template '{{ (index (gitHubTags "twpayne/chezmoi") 0).Name }}' +stdout ^v2\. + +# test gitHubReleases template functions +exec chezmoi execute-template '{{ (index (gitHubReleases "twpayne/chezmoi") 0).TagName }}' +stdout ^v2\. + +# gitHubReleases +# gitHubTags diff --git a/internal/cmd/testdata/scripts/gopass.txt b/internal/cmd/testdata/scripts/gopass.txtar similarity index 65% rename from internal/cmd/testdata/scripts/gopass.txt rename to internal/cmd/testdata/scripts/gopass.txtar index 75432da601f..6a92c3c49eb 100644 --- a/internal/cmd/testdata/scripts/gopass.txt +++ b/internal/cmd/testdata/scripts/gopass.txtar @@ -1,13 +1,14 @@ -[!windows] chmod 755 bin/gopass +[unix] chmod 755 bin/gopass [windows] unix2dos bin/gopass.cmd +[windows] unix2dos golden/gopass-raw # test gopass template function -chezmoi execute-template '{{ gopass "misc/example.com" }}' -stdout examplepassword +exec chezmoi execute-template '{{ gopass "misc/example.com" }}' +stdout ^examplepassword$ # test gopass template function -chezmoi execute-template '{{ gopassRaw "misc/example.com" }}' -stdout 'Secret: misc/example\.com' +exec chezmoi execute-template '{{ gopassRaw "misc/example.com" }}' +cmp stdout golden/gopass-raw -- bin/gopass -- #!/bin/sh @@ -34,10 +35,10 @@ esac IF "%*" == "--version" ( echo "gopass 1.10.1 go1.15 windows amd64" ) ELSE IF "%*" == "show --noparsing misc/example.com" ( - echo "Secret: misc/example.com" - echo - echo "examplepassword" - echo "key: value" + echo.Secret: misc/example.com + echo. + echo.examplepassword + echo.key: value ) ELSE IF "%*" == "show --password misc/example.com" ( echo | set /p=examplepassword exit /b 0 @@ -45,3 +46,8 @@ IF "%*" == "--version" ( echo gopass: invalid command: %* exit /b 1 ) +-- golden/gopass-raw -- +Secret: misc/example.com + +examplepassword +key: value diff --git a/internal/cmd/testdata/scripts/gpg.txt b/internal/cmd/testdata/scripts/gpg.txtar similarity index 74% rename from internal/cmd/testdata/scripts/gpg.txt rename to internal/cmd/testdata/scripts/gpg.txtar index a14629f9a55..bc58d72b4f7 100644 --- a/internal/cmd/testdata/scripts/gpg.txt +++ b/internal/cmd/testdata/scripts/gpg.txtar @@ -1,3 +1,4 @@ +[windows] skip 'skipping gpg tests on Windows' [!exec:gpg] skip 'gpg not found in $PATH' mkhomedir @@ -5,17 +6,17 @@ mkgpgconfig # test that chezmoi add --encrypt encrypts cp golden/.encrypted $HOME -chezmoi add --encrypt $HOME${/}.encrypted +exec chezmoi add --encrypt $HOME${/}.encrypted exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc grep '-----BEGIN PGP MESSAGE-----' $CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc # test that chezmoi apply decrypts rm $HOME/.encrypted -chezmoi apply --force +exec chezmoi apply --force cmp golden/.encrypted $HOME/.encrypted # test that chezmoi edit --apply transparently decrypts and re-encrypts -chezmoi edit --apply --force $HOME${/}.encrypted +exec chezmoi edit --apply --force $HOME${/}.encrypted grep '# edited' $HOME/.encrypted -- golden/.encrypted -- diff --git a/internal/cmd/testdata/scripts/gpgencryption.txt b/internal/cmd/testdata/scripts/gpgencryption.txtar similarity index 73% rename from internal/cmd/testdata/scripts/gpgencryption.txt rename to internal/cmd/testdata/scripts/gpgencryption.txtar index 72aa6b2171c..af1ce98f5b5 100644 --- a/internal/cmd/testdata/scripts/gpgencryption.txt +++ b/internal/cmd/testdata/scripts/gpgencryption.txtar @@ -1,3 +1,4 @@ +[windows] skip 'skipping gpg tests on Windows' [!exec:gpg] skip 'gpg not found in $PATH' mkhomedir @@ -5,60 +6,60 @@ mkgpgconfig # test that chezmoi add --encrypt encrypts cp golden/.encrypted $HOME -chezmoi add --encrypt $HOME${/}.encrypted +exec chezmoi add --encrypt $HOME${/}.encrypted exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc grep '-----BEGIN PGP MESSAGE-----' $CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc # test that chezmoi apply decrypts rm $HOME/.encrypted -chezmoi apply --force +exec chezmoi apply --force cmp golden/.encrypted $HOME/.encrypted # test that chezmoi apply --exclude=encrypted does not apply encrypted files rm $HOME/.encrypted -chezmoi apply --exclude=encrypted --force +exec chezmoi apply --exclude=encrypted --force ! exists $HOME/.encrypted -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.encrypted golden/.encrypted # test that chezmoi detects gpg encryption if gpg is configured but encryption = "gpg" is not set removeline $CHEZMOICONFIGDIR/chezmoi.toml 'encryption = "gpg"' -chezmoi cat $HOME${/}.encrypted +exec chezmoi cat $HOME${/}.encrypted cmp stdout golden/.encrypted # test that chezmoi decrypt decrypts stdin stdin $CHEZMOISOURCEDIR${/}encrypted_dot_encrypted.asc -chezmoi decrypt +exec chezmoi decrypt cmp stdout golden/.encrypted # test that chezmoi decrypt decrypts a file -chezmoi decrypt $CHEZMOISOURCEDIR${/}encrypted_dot_encrypted.asc +exec chezmoi decrypt $CHEZMOISOURCEDIR${/}encrypted_dot_encrypted.asc cmp stdout golden/.encrypted # test chezmoi encrypt/chezmoi decrypt round trip -chezmoi encrypt golden/.encrypted +exec chezmoi encrypt golden/.encrypted stdout '-----BEGIN PGP MESSAGE-----' stdin stdout -chezmoi decrypt +exec chezmoi decrypt cmp stdout golden/.encrypted # test that chezmoi edit --apply transparently decrypts and re-encrypts -chezmoi edit --apply --force $HOME${/}.encrypted +exec chezmoi edit --apply --force $HOME${/}.encrypted grep '# edited' $HOME/.encrypted # test that chezmoi files in subdirectories can be encrypted and that suffix can be set appendline $CHEZMOICONFIGDIR/chezmoi.toml ' suffix = ".gpg"' mkdir $HOME/.dir cp golden/.encrypted $HOME/.dir -chezmoi add --encrypt $HOME${/}.dir${/}.encrypted +exec chezmoi add --encrypt $HOME${/}.dir${/}.encrypted grep '-----BEGIN PGP MESSAGE-----' $CHEZMOISOURCEDIR/dot_dir/encrypted_dot_encrypted.gpg -chezmoi edit --apply $HOME${/}.dir${/}.encrypted +exec chezmoi edit --apply $HOME${/}.dir${/}.encrypted grep '# edited' $HOME/.dir/.encrypted # test that chezmoi edit strips the encrypted suffix -[!windows] env EDITOR=echo +[unix] env EDITOR=echo [windows] env EDITOR=printargs -chezmoi edit $HOME${/}.dir${/}.encrypted +exec chezmoi edit $HOME${/}.dir${/}.encrypted stdout '\.dir/\.encrypted\r?$' -- bin/printargs.cmd -- diff --git a/internal/cmd/testdata/scripts/gpgencryptionsymmetric.txt b/internal/cmd/testdata/scripts/gpgencryptionsymmetric.txtar similarity index 75% rename from internal/cmd/testdata/scripts/gpgencryptionsymmetric.txt rename to internal/cmd/testdata/scripts/gpgencryptionsymmetric.txtar index 3e746a31111..12d47c18951 100644 --- a/internal/cmd/testdata/scripts/gpgencryptionsymmetric.txt +++ b/internal/cmd/testdata/scripts/gpgencryptionsymmetric.txtar @@ -1,3 +1,4 @@ +[windows] skip 'skipping gpg tests on Windows' [!exec:gpg] skip 'gpg not found in $PATH' mkhomedir @@ -5,38 +6,38 @@ mkgpgconfig -symmetric # test that chezmoi add --encrypt encrypts cp golden/.encrypted $HOME -chezmoi add --encrypt $HOME${/}.encrypted +exec chezmoi add --encrypt $HOME${/}.encrypted exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc grep '-----BEGIN PGP MESSAGE-----' $CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc # test that chezmoi apply decrypts rm $HOME/.encrypted -chezmoi apply --force +exec chezmoi apply --force cmp golden/.encrypted $HOME/.encrypted # test that chezmoi apply --exclude=encrypted does not apply encrypted files rm $HOME/.encrypted -chezmoi apply --exclude=encrypted --force +exec chezmoi apply --exclude=encrypted --force ! exists $HOME/.encrypted -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.encrypted golden/.encrypted # test that chezmoi detects gpg encryption if gpg is configured but encryption = "gpg" is not set removeline $CHEZMOICONFIGDIR/chezmoi.toml 'encryption = "gpg"' -chezmoi cat $HOME${/}.encrypted +exec chezmoi cat $HOME${/}.encrypted cmp stdout golden/.encrypted # test that chezmoi edit --apply transparently decrypts and re-encrypts -chezmoi edit --apply --force $HOME${/}.encrypted +exec chezmoi edit --apply --force $HOME${/}.encrypted grep '# edited' $HOME/.encrypted # test that chezmoi files in subdirectories can be encrypted and that suffix can be set appendline $CHEZMOICONFIGDIR/chezmoi.toml ' suffix = ".gpg"' mkdir $HOME/.dir cp golden/.encrypted $HOME/.dir -chezmoi add --encrypt $HOME${/}.dir${/}.encrypted +exec chezmoi add --encrypt $HOME${/}.dir${/}.encrypted grep '-----BEGIN PGP MESSAGE-----' $CHEZMOISOURCEDIR/dot_dir/encrypted_dot_encrypted.gpg -chezmoi edit --apply $HOME${/}.dir${/}.encrypted +exec chezmoi edit --apply $HOME${/}.dir${/}.encrypted grep '# edited' $HOME/.dir/.encrypted -- golden/.encrypted -- diff --git a/internal/cmd/testdata/scripts/hcpvaultsecrets.txtar b/internal/cmd/testdata/scripts/hcpvaultsecrets.txtar new file mode 100644 index 00000000000..9a6f819c3ce --- /dev/null +++ b/internal/cmd/testdata/scripts/hcpvaultsecrets.txtar @@ -0,0 +1,70 @@ +[windows] skip 'UNIX only' +[unix] chmod 755 bin/vlt + +# test hcpVaultSecret template function +exec chezmoi execute-template '{{ hcpVaultSecret "password" }}' +stdout ^correcthorsebatterystaple$ + +# test hcpVaultSecret template function with app name, project, and organization arguments +exec chezmoi execute-template '{{ hcpVaultSecret "password" "application-name" "project-id" "organization-id" }}' +stdout ^correcthorsebatterystaple$ + +# test hcpVaultSecret template function with empty app name, project, and organization arguments +exec chezmoi execute-template '{{ hcpVaultSecret "password" "" "" "" }}' +stdout ^correcthorsebatterystaple$ + +# test hcpVaultSecretJson template function +exec chezmoi execute-template '{{ (hcpVaultSecretJson "password").created_by.email }}' +stdout ^user@example\.com$ + +chhome home2/user + +# test hcpVaultSecret template function with default app name, project, and organization arguments +exec chezmoi execute-template '{{ hcpVaultSecret "password" }}' +stdout ^default-password$ + +# test hcpVaultSecretJson template function with default project and organization arguments +exec chezmoi execute-template '{{ hcpVaultSecret "password" "other-app-name" }}' +stdout ^other-password$ + +-- bin/vlt -- +#!/bin/sh + +case "$*" in +"secrets get --format json password") + cat < .file exec tar czf archive.tar.gz archive # test that chezmoi import imports an archive -chezmoi import --strip-components=1 archive.tar.gz +exec chezmoi import --strip-components=1 archive.tar.gz cmp $CHEZMOISOURCEDIR/dot_dir/dot_file golden/dot_dir/dot_file -[!windows] cmp $CHEZMOISOURCEDIR/dot_dir/symlink_dot_symlink golden/dot_dir/symlink_dot_symlink # FIXME this should pass on Windows +[unix] cmp $CHEZMOISOURCEDIR/dot_dir/symlink_dot_symlink golden/dot_dir/symlink_dot_symlink # FIXME this should pass on Windows # test that chezmoi import run a second time overwrites -chezmoi import --strip-components=1 archive.tar.gz +exec chezmoi import --strip-components=1 archive.tar.gz chhome home2/user # test chezmoi import --destination -chezmoi import --strip-components=1 --destination=$HOME${/}.subdir archive.tar.gz +exec chezmoi import --strip-components=1 --destination=$HOME${/}.subdir archive.tar.gz cmp $CHEZMOISOURCEDIR/dot_subdir/dot_dir/dot_file golden/dot_dir/dot_file -[!windows] cmp $CHEZMOISOURCEDIR/dot_subdir/dot_dir/symlink_dot_symlink golden/dot_dir/symlink_dot_symlink # FIXME this should pass on Windows +[unix] cmp $CHEZMOISOURCEDIR/dot_subdir/dot_dir/symlink_dot_symlink golden/dot_dir/symlink_dot_symlink # FIXME this should pass on Windows chhome home3/user # test chezmoi --import --exclude -chezmoi import --strip-components=1 --destination=$HOME${/}.subdir --exclude=symlinks archive.tar.gz +exec chezmoi import --strip-components=1 --destination=$HOME${/}.subdir --exclude=symlinks archive.tar.gz cmp $CHEZMOISOURCEDIR/dot_subdir/dot_dir/dot_file golden/dot_dir/dot_file ! exists $CHEZMOISOURCEDIR/dot_subdir/dot_dir/symlink_dot_symlink diff --git a/internal/cmd/testdata/scripts/importtarzst.txtar b/internal/cmd/testdata/scripts/importtarzst.txtar new file mode 100644 index 00000000000..9cfb6b7bb08 --- /dev/null +++ b/internal/cmd/testdata/scripts/importtarzst.txtar @@ -0,0 +1,13 @@ +[(openbsd||windows)] skip 'tar does not support zstd compression' +[!exec:zstd] skip 'zstd not found in $PATH' +exec tar -c -f archive.tar.zst --use-compress-program zstd archive + +# test that chezmoi import imports a tar.zst archive +exec chezmoi import --destination=$HOME${/}.dir --strip-components=1 archive.tar.zst +cmp $CHEZMOISOURCEDIR/dot_dir/dir/file golden/dot_dir/dir/file + +-- archive/dir/file -- +# contents of dir/file +-- golden/dot_dir/dir/file -- +# contents of dir/file +-- home/user/.local/share/chezmoi/dot_dir/.keep -- diff --git a/internal/cmd/testdata/scripts/importxz.txtar b/internal/cmd/testdata/scripts/importxz.txtar new file mode 100644 index 00000000000..d8cc8360c76 --- /dev/null +++ b/internal/cmd/testdata/scripts/importxz.txtar @@ -0,0 +1,13 @@ +[(openbsd||windows)] skip 'tar does not support XZ compression' +[!exec:xz] skip 'xz not found in $PATH' +exec tar cJf archive.tar.xz archive + +# test that chezmoi import imports a tar.xz archive +exec chezmoi import --destination=$HOME${/}.dir --strip-components=1 archive.tar.xz +cmp $CHEZMOISOURCEDIR/dot_dir/dir/file golden/dot_dir/dir/file + +-- archive/dir/file -- +# contents of dir/file +-- golden/dot_dir/dir/file -- +# contents of dir/file +-- home/user/.local/share/chezmoi/dot_dir/.keep -- diff --git a/internal/cmd/testdata/scripts/importzip.txt b/internal/cmd/testdata/scripts/importzip.txtar similarity index 80% rename from internal/cmd/testdata/scripts/importzip.txt rename to internal/cmd/testdata/scripts/importzip.txtar index 21e03eb0f49..42c5daa312a 100644 --- a/internal/cmd/testdata/scripts/importzip.txt +++ b/internal/cmd/testdata/scripts/importzip.txtar @@ -2,7 +2,7 @@ exec zip -r archive.zip archive # test that chezmoi import imports a zip archive -chezmoi import --destination=$HOME${/}.dir --strip-components=1 archive.zip +exec chezmoi import --destination=$HOME${/}.dir --strip-components=1 archive.zip cmp $CHEZMOISOURCEDIR/dot_dir/dir/file golden/dot_dir/dir/file -- archive/dir/file -- diff --git a/internal/cmd/testdata/scripts/init.txt b/internal/cmd/testdata/scripts/init.txtar similarity index 51% rename from internal/cmd/testdata/scripts/init.txt rename to internal/cmd/testdata/scripts/init.txtar index 2738304f899..8fb9237e6fd 100644 --- a/internal/cmd/testdata/scripts/init.txt +++ b/internal/cmd/testdata/scripts/init.txtar @@ -5,20 +5,20 @@ mkhomedir golden mkhomedir # test that chezmoi init creates a git repo -chezmoi init +exec chezmoi init exists $CHEZMOISOURCEDIR/.git # create a commit cp golden/chezmoi.toml $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl cp golden/.file $CHEZMOISOURCEDIR/dot_file -chezmoi git add . -chezmoi git commit -- --message 'Initial commit' +exec chezmoi git add . +exec chezmoi git commit -- --message 'Initial commit' chhome home2/user # test that chezmoi init fetches git repo but does not apply mkgitconfig -chezmoi init file://$WORK/home/user/.local/share/chezmoi +exec chezmoi init file://$WORK/home/user/.local/share/chezmoi exists $CHEZMOISOURCEDIR/.git ! exists $HOME/.file @@ -26,7 +26,7 @@ chhome home3/user # test that chezmoi init --apply fetches a git repo and runs chezmoi apply mkgitconfig -chezmoi init --apply --force file://$WORK/home/user/.local/share/chezmoi +exec chezmoi init --apply --force file://$WORK/home/user/.local/share/chezmoi exists $CHEZMOISOURCEDIR/.git cmp $HOME/.file golden/.file @@ -36,7 +36,7 @@ chhome home4/user mkgitconfig exists $CHEZMOICONFIGDIR ! exists $CHEZMOISOURCEDIR -chezmoi init --apply --depth 1 --force --purge file://$WORK/home/user/.local/share/chezmoi +exec chezmoi init --apply --depth 1 --force --purge file://$WORK/home/user/.local/share/chezmoi cmp $HOME/.file golden/.file ! exists $CHEZMOICONFIGDIR ! exists $CHEZMOISOURCEDIR @@ -45,14 +45,14 @@ chhome home5/user # test that chezmoi init does not clone the repo if it is already checked out but does create the config file mkgitconfig -chezmoi init --source=$HOME/dotfiles file://$WORK/nonexistentrepo +exec chezmoi init --source=$HOME/dotfiles file://$WORK/nonexistentrepo exists $CHEZMOICONFIGDIR/chezmoi.toml chhome home6/user # test chezmoi init --one-shot mkgitconfig -chezmoi init --one-shot file://$WORK/home/user/.local/share/chezmoi +exec chezmoi init --one-shot file://$WORK/home/user/.local/share/chezmoi cmp $HOME/.file golden/.file ! exists $CHEZMOICONFIGDIR ! exists $CHEZMOISOURCEDIR @@ -61,68 +61,114 @@ chhome home7/user # test chezmoi init --data=true mkgitconfig -chezmoi init --data=true file://$WORK/home/user/.local/share/chezmoi +exec chezmoi init --data=true file://$WORK/home/user/.local/share/chezmoi cmp $CHEZMOICONFIGDIR/chezmoi.toml golden/chezmoi.toml # test chezmoi init --data=false -chezmoi init --data=false file://$WORK/home/user/.local/share/chezmoi +exec chezmoi init --data=false file://$WORK/home/user/.local/share/chezmoi cmp $CHEZMOICONFIGDIR/chezmoi.toml golden/chezmoi.toml-no-data chhome home8/user # test that chezmoi init fails if the generated config is not valid mkgitconfig -! chezmoi init -stderr 'parsing error' +! exec chezmoi init +stderr '\.chezmoi\.toml\.tmpl: toml: expected character =' ! exists .config/chezmoi chhome home/user # create a new branch -chezmoi git checkout -- -b new-branch +exec chezmoi git checkout -- -b new-branch edit $CHEZMOISOURCEDIR/dot_file -chezmoi git add dot_file -chezmoi git commit -- --message 'Edit .file' -chezmoi git checkout master +exec chezmoi git add dot_file +exec chezmoi git commit -- --message 'Edit .file' +exec chezmoi git checkout master chhome home9/user # test chezmoi init --branch mkgitconfig -chezmoi init --apply --branch=new-branch file://$WORK/home/user/.local/share/chezmoi +exec chezmoi init --apply --branch=new-branch file://$WORK/home/user/.local/share/chezmoi grep '# edited' $HOME/.file chhome home10/user -# test chezmoi init --config-path +# test chezmoi --config init mkgitconfig -chezmoi init --config-path=$HOME/.chezmoi.toml file://$WORK/home/user/.local/share/chezmoi +exec chezmoi --config=$HOME/.chezmoi.toml init file://$WORK/home/user/.local/share/chezmoi cmp $HOME/.chezmoi.toml golden/chezmoi.toml ! exists $CHEZMOICONFIGDIR/chezmoi.toml chhome home11/user +# test chezmoi init --config-path +mkgitconfig +exec chezmoi init --config-path=$HOME/.chezmoi.toml file://$WORK/home/user/.local/share/chezmoi +cmp $HOME/.chezmoi.toml golden/chezmoi.toml +! exists $CHEZMOICONFIGDIR/chezmoi.toml + +chhome home12/user + # test chezmoi init when the source dir is already in a git working copy mkgitconfig exec git init $HOME/.local/share -chezmoi init +exec chezmoi init ! exists $CHEZMOISOURCEDIR/.git +chhome home13/user + +# test chezmoi init --prompt* +exec chezmoi init --promptBool bool=true,boolOncePrompt=true --promptChoice=choice=one,choiceOncePrompt=two --promptInt int=1,intOncePrompt=2 --promptString bool=string,stringOncePrompt=once +cmp $CHEZMOICONFIGDIR/chezmoi.yaml golden/chezmoi.yaml + +chhome home14/user + +# test that chezmoi init creates a config file with a .yml extension +exec chezmoi init +exists $CHEZMOICONFIGDIR/chezmoi.yml + +# test that chezmoi reads a config file with a .yml extension +exec chezmoi execute-template '{{ .key }}' +stdout ^value$ + -- golden/chezmoi.toml -- [data] email = "firstname.lastname@company.com" -- golden/chezmoi.toml-no-data -- [data] email = "me@home.org" +-- golden/chezmoi.yaml -- +data: + bool: true + boolOnce: true + choice: one + choiceOnce: two + int: 1 + intOnce: 2 + string: string + stringOnce: once +-- home13/user/.local/share/chezmoi/.chezmoi.yaml.tmpl -- +data: + bool: {{ promptBool "bool" }} + boolOnce: {{ promptBoolOnce . "boolOnce" "boolOncePrompt" }} + choice: {{ promptChoice "choice" (list "one" "two" "three") }} + choiceOnce: {{ promptChoiceOnce . "choiceOnce" "choiceOncePrompt" (list "one" "two" "three") }} + int: {{ promptInt "int" }} + intOnce: {{ promptIntOnce . "intOnce" "intOncePrompt" }} + string: {{ promptString "bool" }} + stringOnce: {{ promptStringOnce . "stringOnce" "stringOncePrompt" }} +-- home14/user/.local/share/chezmoi/.chezmoi.yml.tmpl -- +data: + key: value -- home4/user/.config/chezmoi/chezmoi.toml -- --- home5/user/dotfiles/.git/.keep -- -- home5/user/dotfiles/.chezmoi.toml.tmpl -- [data] email = "me@home.org" +-- home5/user/dotfiles/.git/.keep -- -- home7/user/.config/chezmoi/chezmoi.toml -- [data] email = "firstname.lastname@company.com" --- home7/user/.local/share/chezmoi/.git/.keep -- -- home7/user/.local/share/chezmoi/.chezmoi.toml.tmpl -- {{- $email := get . "email" -}} {{- if not $email -}} @@ -130,6 +176,7 @@ chezmoi init {{- end -}} [data] email = {{ $email | quote }} +-- home7/user/.local/share/chezmoi/.git/.keep -- -- home8/user/.local/share/chezmoi/.chezmoi.toml.tmpl -- [diff] exclude: ["scripts"] diff --git a/internal/cmd/testdata/scripts/initconfig.txtar b/internal/cmd/testdata/scripts/initconfig.txtar new file mode 100644 index 00000000000..d832f5ca31f --- /dev/null +++ b/internal/cmd/testdata/scripts/initconfig.txtar @@ -0,0 +1,125 @@ +[windows] skip 'test requires path separator to be forward slash' + +mkdir $CHEZMOISOURCEDIR + +# test that chezmoi init writes the initial config into the default config dir +cp golden/chezmoi1.yaml $CHEZMOISOURCEDIR/.chezmoi.yaml.tmpl +exec chezmoi init +cmp $CHEZMOICONFIGDIR/chezmoi.yaml golden/chezmoi1.yaml + +# test that chezmoi init writes an updated config into the default config dir +cp golden/chezmoi2.yaml $CHEZMOISOURCEDIR/.chezmoi.yaml.tmpl +exec chezmoi init +cmp $CHEZMOICONFIGDIR/chezmoi.yaml golden/chezmoi2.yaml + +# test that chezmoi init writes a config of a new format into the default config dir +rm $CHEZMOISOURCEDIR/.chezmoi.yaml.tmpl +cp golden/chezmoi3.toml $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl +exec chezmoi init +cmp $CHEZMOICONFIGDIR/chezmoi.yaml golden/chezmoi2.yaml +cmp $CHEZMOICONFIGDIR/chezmoi.toml golden/chezmoi3.toml + +# check that the last operation broke chezmoi +! exec chezmoi status +! stdout . +cmpenv stderr golden/error1.log + +# check that deleting the old config file fixes the issue +rm $CHEZMOICONFIGDIR/chezmoi.yaml +exec chezmoi status +! stdout . +! stderr . + +# check that the state file was written into the default config dir +exists $CHEZMOICONFIGDIR/chezmoistate.boltdb + +chhome home2/user + +mkdir $CHEZMOISOURCEDIR + +# test that chezmoi --config=path init writes the initial config into path +cp golden/chezmoi1.yaml $CHEZMOISOURCEDIR/.chezmoi.yaml.tmpl +exec chezmoi --config=$HOME/.chezmoi/athome.yaml init +cmp $HOME/.chezmoi/athome.yaml golden/chezmoi1.yaml + +# test that chezmoi --config=path init writes an updated config into path +cp golden/chezmoi2.yaml $CHEZMOISOURCEDIR/.chezmoi.yaml.tmpl +exec chezmoi --config=$HOME/.chezmoi/athome.yaml init +cmp $HOME/.chezmoi/athome.yaml golden/chezmoi2.yaml + +# test that chezmoi --config=path init writes a config of a new format into path +rm $CHEZMOISOURCEDIR/.chezmoi.yaml.tmpl +cp golden/chezmoi3.toml $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl +exec chezmoi --config=$HOME/.chezmoi/athome.yaml init +cmp $HOME/.chezmoi/athome.yaml golden/chezmoi3.toml + +# check that the last operation broke chezmoi +! exec chezmoi --config=$HOME/.chezmoi/athome.yaml status +! stdout . +cmpenv stderr golden/error2.log + +# check that renaming the file and updating the config path fixes the issue +mv $HOME/.chezmoi/athome.yaml $HOME/.chezmoi/athome.toml +exec chezmoi --config=$HOME/.chezmoi/athome.toml status +! stdout . +! stderr . + +# check that the state file was written next to the config file +exists $HOME/.chezmoi/chezmoistate.boltdb + +# check that nothing was ever written into the default config dir +! exists $CHEZMOICONFIGDIR/chezmoi.toml +! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb + +chhome home3/user + +mkdir $CHEZMOISOURCEDIR + +# test that chezmoi --config=path --config-format=format init writes the initial config into path +cp golden/chezmoi1.yaml $CHEZMOISOURCEDIR/.chezmoi.yaml.tmpl +exec chezmoi --config=$HOME/.chezmoi/athome.txt --config-format=yaml init +cmp $HOME/.chezmoi/athome.txt golden/chezmoi1.yaml + +# test that chezmoi --config=path --config-format=format init writes an updated config into path +cp golden/chezmoi2.yaml $CHEZMOISOURCEDIR/.chezmoi.yaml.tmpl +exec chezmoi --config=$HOME/.chezmoi/athome.txt --config-format=yaml init +cmp $HOME/.chezmoi/athome.txt golden/chezmoi2.yaml + +# test that chezmoi --config=path --config-format=format init writes a config of a new format into path +rm $CHEZMOISOURCEDIR/.chezmoi.yaml.tmpl +cp golden/chezmoi3.toml $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl +exec chezmoi --config=$HOME/.chezmoi/athome.txt --config-format=yaml init +cmp $HOME/.chezmoi/athome.txt golden/chezmoi3.toml + +# check that the last operation broke chezmoi +! exec chezmoi --config=$HOME/.chezmoi/athome.txt --config-format=yaml status +! stdout . +cmpenv stderr golden/error3.log + +# check that updating the config format fixes the issue +exec chezmoi --config=$HOME/.chezmoi/athome.txt --config-format=toml status +! stdout . +! stderr . + +# check that the state file was written next to the config file +exists $HOME/.chezmoi/chezmoistate.boltdb + +# check that nothing was ever written into the default config dir +! exists $CHEZMOICONFIGDIR/chezmoi.toml +! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb + +-- golden/chezmoi1.yaml -- +data: + email: "mail1@example.com" +-- golden/chezmoi2.yaml -- +data: + email: "mail2@example.com" +-- golden/chezmoi3.toml -- +[data] + email = "mail3@example.com" +-- golden/error1.log -- +chezmoi: multiple config files: $CHEZMOICONFIGDIR/chezmoi.toml and $CHEZMOICONFIGDIR/chezmoi.yaml +-- golden/error2.log -- +chezmoi: invalid config: $HOME/.chezmoi/athome.yaml: yaml: unmarshal errors: line 1: cannot unmarshal !!seq into map[string]interface {} +-- golden/error3.log -- +chezmoi: invalid config: $HOME/.chezmoi/athome.txt: yaml: unmarshal errors: line 1: cannot unmarshal !!seq into map[string]interface {} diff --git a/internal/cmd/testdata/scripts/inittemplatefuncs.txtar b/internal/cmd/testdata/scripts/inittemplatefuncs.txtar new file mode 100644 index 00000000000..c1cb4e84b94 --- /dev/null +++ b/internal/cmd/testdata/scripts/inittemplatefuncs.txtar @@ -0,0 +1,87 @@ +# test exit template function +exec chezmoi execute-template --init '{{ exit 0 }}' +! exec chezmoi execute-template --init '{{ exit 1 }}' + +# test promptBoolOnce template function with execute-template --init +exec chezmoi execute-template --init --promptBool bool=true '{{ promptBoolOnce . "bool" "bool" }}' +stdout true + +# test promptChoiceOnce template function with execute-template --init +exec chezmoi execute-template --init --promptChoice choice=one '{{ promptChoiceOnce . "choice" "choice" (list "one" "two" "three") }}' +stdout one + +# test promptIntOnce template function with execute-template --init +exec chezmoi execute-template --init --promptInt int=1 '{{ promptIntOnce . "int" "int" }}' +stdout 1 + +# test promptStringOnce template function with execute-template --init +exec chezmoi execute-template --init --promptString string=value '{{ promptStringOnce . "string" "string" }}' +stdout value + +# test writeToStdout template function +exec chezmoi execute-template --init '{{ writeToStdout "string" }}' +stdout string + +# test prompt*Once functions without existing data +stdin golden/input +exec chezmoi init --no-tty +cmp ${CHEZMOICONFIGDIR}/chezmoi.toml golden/chezmoi.toml + +chhome home2/user + +# test prompt*Once functions with existing data +exec chezmoi init +cmp ${CHEZMOICONFIGDIR}/chezmoi.toml golden/chezmoi.toml + +chhome home3/user + +# test prompt*Once functions with existing data and nested keys +exec chezmoi init +cmp ${CHEZMOICONFIGDIR}/chezmoi.toml golden/chezmoi.toml + +-- golden/chezmoi.toml -- +[data] + bool = true + int = 1 + string = "value" +-- golden/input -- +true +1 +value +-- home/user/.local/share/chezmoi/.chezmoi.toml.tmpl -- +{{ $bool := promptBoolOnce . "bool" "bool" -}} +{{ $int := promptIntOnce . "int" "int" -}} +{{ $string := promptStringOnce . "string" "string" -}} + +[data] + bool = {{ $bool }} + int = {{ $int }} + string = {{ $string | quote }} +-- home2/user/.config/chezmoi/chezmoi.toml -- +[data] + bool = true + int = 1 + string = "value" +-- home2/user/.local/share/chezmoi/.chezmoi.toml.tmpl -- +{{ $bool := promptBoolOnce . "bool" "bool" -}} +{{ $int := promptIntOnce . "int" "int" -}} +{{ $string := promptStringOnce . "string" "string" -}} + +[data] + bool = {{ $bool }} + int = {{ $int }} + string = {{ $string | quote }} +-- home3/user/.config/chezmoi/chezmoi.toml -- +[data] + nested.bool = true + nested.int = 1 + nested.string = "value" +-- home3/user/.local/share/chezmoi/.chezmoi.toml.tmpl -- +{{ $bool := promptBoolOnce . "nested.bool" "bool" -}} +{{ $int := promptIntOnce . "nested.int" "int" -}} +{{ $string := promptStringOnce . "nested.string" "string" -}} + +[data] + bool = {{ $bool }} + int = {{ $int }} + string = {{ $string | quote }} diff --git a/internal/cmd/testdata/scripts/issue1161.txt b/internal/cmd/testdata/scripts/issue1161.txtar similarity index 80% rename from internal/cmd/testdata/scripts/issue1161.txt rename to internal/cmd/testdata/scripts/issue1161.txtar index e827c9ed838..c21247dda84 100644 --- a/internal/cmd/testdata/scripts/issue1161.txt +++ b/internal/cmd/testdata/scripts/issue1161.txtar @@ -1,15 +1,15 @@ # test that chezmoi does not follow symlinks in the source directory if the file should be ignored symlink $CHEZMOISOURCEDIR/.HASH_dot_file -> non_existent_file -chezmoi status +exec chezmoi status rm $CHEZMOISOURCEDIR/.HASH_dot_file -chezmoi status +exec chezmoi status # test that chezmoi does follow symlinks in the source directory if the file should not be ignored symlink $CHEZMOISOURCEDIR/.chezmoifile -> non_existent_file -! chezmoi status -[!windows] stderr 'no such file or directory' +! exec chezmoi status +[unix] stderr 'no such file or directory' [windows] stderr 'The system cannot find the file specified' rm $CHEZMOISOURCEDIR/.chezmoifile -chezmoi status +exec chezmoi status -- home/user/.local/share/chezmoi/.keep -- diff --git a/internal/cmd/testdata/scripts/issue1213.txt b/internal/cmd/testdata/scripts/issue1213.txtar similarity index 72% rename from internal/cmd/testdata/scripts/issue1213.txt rename to internal/cmd/testdata/scripts/issue1213.txtar index 3af583556a2..579298b42c8 100644 --- a/internal/cmd/testdata/scripts/issue1213.txt +++ b/internal/cmd/testdata/scripts/issue1213.txtar @@ -3,23 +3,23 @@ mkgitconfig # create a repo -chezmoi init +exec chezmoi init exists $CHEZMOISOURCEDIR/.git cp golden/.chezmoi.toml.tmpl $CHEZMOISOURCEDIR cp golden/dot_file.tmpl $CHEZMOISOURCEDIR -chezmoi git add . -chezmoi git commit -- --message 'Initial commit' +exec chezmoi git add . +exec chezmoi git commit -- --message 'Initial commit' chhome home2/user # test that chezmoi init --apply makes config template data available mkgitconfig -chezmoi init --apply file://$WORK/home/user/.local/share/chezmoi +exec chezmoi init --apply file://$WORK/home/user/.local/share/chezmoi cmp $HOME/.file golden/.file -- golden/.chezmoi.toml.tmpl -- [data.me.github] - username = "user" + username = "user" -- golden/.file -- username = "user" -- golden/dot_file.tmpl -- diff --git a/internal/cmd/testdata/scripts/issue1237.txtar b/internal/cmd/testdata/scripts/issue1237.txtar new file mode 100644 index 00000000000..f87bea4388a --- /dev/null +++ b/internal/cmd/testdata/scripts/issue1237.txtar @@ -0,0 +1,13 @@ +# test that chezmoi add does not add ignored directories +exec chezmoi add ~/.config +exec chezmoi managed +cmp stdout golden/managed +exists $CHEZMOISOURCEDIR/dot_config/.keep + +-- golden/managed -- +.config +-- home/user/.config/Bitwarden CLI/file -- +# contents of .config/Bitwarden CLI/file +-- home/user/.local/share/chezmoi/.chezmoiignore -- +.config/Bitwarden CLI +.config/Bitwarden CLI/** diff --git a/internal/cmd/testdata/scripts/issue1365.txt b/internal/cmd/testdata/scripts/issue1365.txtar similarity index 90% rename from internal/cmd/testdata/scripts/issue1365.txt rename to internal/cmd/testdata/scripts/issue1365.txtar index f0f2aba78e6..07c0f1e8d9f 100644 --- a/internal/cmd/testdata/scripts/issue1365.txt +++ b/internal/cmd/testdata/scripts/issue1365.txtar @@ -3,18 +3,18 @@ # test that chezmoi diff generates the correct output when chezmoi's config file is managed by chezmoi chmod 700 $CHEZMOICONFIGDIR chmod 600 $CHEZMOICONFIGDIR/chezmoi.toml -chezmoi diff -cmp stdout golden/chezmoi.toml-diff +exec chezmoi diff +cmp stdout golden/chezmoi.toml-diff.diff # test that chezmoi diff generates the correct output when a custom diff tool is set and the file is in a subdirectory cp golden/chezmoi.toml-custom-diff $CHEZMOICONFIGDIR/chezmoi.toml -chezmoi diff +exec chezmoi diff stdout ^$HOME/\.config/chezmoi/chezmoi\.toml\s+$WORK/.*/\.config/chezmoi/chezmoi\.toml$ -- golden/chezmoi.toml-custom-diff -- [diff] command = "echo" --- golden/chezmoi.toml-diff -- +-- golden/chezmoi.toml-diff.diff -- diff --git a/.config/chezmoi/chezmoi.toml b/.config/chezmoi/chezmoi.toml index 63caeb2522e9320690143749a6aee71e8fddd300..b9495f6120fb36c4cbda33cb72700c80f1ebb979 100600 --- a/.config/chezmoi/chezmoi.toml diff --git a/internal/cmd/testdata/scripts/issue1666.txtar b/internal/cmd/testdata/scripts/issue1666.txtar new file mode 100644 index 00000000000..fbcc07b3c8e --- /dev/null +++ b/internal/cmd/testdata/scripts/issue1666.txtar @@ -0,0 +1,16 @@ +[windows] skip 'UNIX only' + +# test that chezmoi apply --verbose does not display scripts that are run always, but does run them +exec chezmoi apply --verbose +cmp stdout golden/apply +! stderr . + +-- golden/apply -- +script +-- home/user/.config/chezmoi/chezmoi.toml -- +[diff] + exclude = ["always"] +-- home/user/.local/share/chezmoi/run_script.sh -- +#!/bin/sh + +echo script diff --git a/internal/cmd/testdata/scripts/issue1794.txtar b/internal/cmd/testdata/scripts/issue1794.txtar new file mode 100644 index 00000000000..7ada3c9c90c --- /dev/null +++ b/internal/cmd/testdata/scripts/issue1794.txtar @@ -0,0 +1,22 @@ +[!exec:age] skip 'age not found in $PATH' + +mkageconfig +mkgitconfig + +# create a dotfile repo with a config file template and an encrypted file +exec chezmoi init +cp $CHEZMOICONFIGDIR/chezmoi.toml $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl +exec chezmoi add --encrypt $HOME/.file +exec chezmoi git add . +exec chezmoi git commit -- --message 'Initial commit' + +chhome home2/user + +# test that chezmoi init --apply uses the configured encryption from the template +exec chezmoi init --apply file://$WORK/home/user/.local/share/chezmoi +cmp $HOME/.file golden/.file + +-- golden/.file -- +# contents of .file +-- home/user/.file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue1832.txtar b/internal/cmd/testdata/scripts/issue1832.txtar new file mode 100644 index 00000000000..b94ce7633be --- /dev/null +++ b/internal/cmd/testdata/scripts/issue1832.txtar @@ -0,0 +1,9 @@ +[windows] skip 'UNIX only' + +# test that chezmoi add succeeds when changing the permissions of an intermediate directory +exec chezmoi add $HOME/.config/fish/config +chmod 700 $HOME/.config +exec chezmoi add --force --recursive=false $HOME/.config + +-- home/user/.config/fish/config -- +# contents of .config/fish/config diff --git a/internal/cmd/testdata/scripts/issue1866.txtar b/internal/cmd/testdata/scripts/issue1866.txtar new file mode 100644 index 00000000000..30fedefc756 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue1866.txtar @@ -0,0 +1,9 @@ +# test that chezmoi ignores emacs symbolic link locks +symlink 'home/user/.local/share/chezmoi/.#lock' -> invalid +exec chezmoi apply +cmp $HOME/.file golden/.file + +-- golden/.file -- +# contents of .file +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue1869.txtar b/internal/cmd/testdata/scripts/issue1869.txtar new file mode 100644 index 00000000000..fdfc2367cc1 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue1869.txtar @@ -0,0 +1,60 @@ +# test concurrent read when there are multiple .chezmoiignore files +exec chezmoi status + +chhome home2/user + +# test concurrent read when there are multitple .chezmoidata. files +exec chezmoi data + +-- home/user/.local/share/chezmoi/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir1/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir1/subdir1/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir1/subdir2/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir1/subdir3/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir2/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir2/subdir1/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir2/subdir2/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir2/subdir3/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir3/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir3/subdir1/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir3/subdir2/.chezmoiignore -- +pattern +-- home/user/.local/share/chezmoi/dir3/subdir3/.chezmoiignore -- +pattern +-- home2/user/.local/share/chezmoi/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir1/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir1/subdir1/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir1/subdir2/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir1/subdir3/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir2/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir2/subdir1/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir2/subdir2/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir2/subdir3/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir3/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir3/subdir1/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir3/subdir2/.chezmoidata.yaml -- +key: value +-- home2/user/.local/share/chezmoi/dir3/subdir3/.chezmoidata.yaml -- +key: value diff --git a/internal/cmd/testdata/scripts/issue2016.txtar b/internal/cmd/testdata/scripts/issue2016.txtar new file mode 100644 index 00000000000..4b4ea4e524c --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2016.txtar @@ -0,0 +1,8 @@ +symlink $HOME/.symlink -> .file + +# test that chezmoi apply removes broken symlinks +exec chezmoi apply +! lexists $HOME/.symlink + +-- home/user/.local/share/chezmoi/.chezmoiremove -- +.symlink diff --git a/internal/cmd/testdata/scripts/issue2092.txtar b/internal/cmd/testdata/scripts/issue2092.txtar new file mode 100644 index 00000000000..5f77d5ee175 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2092.txtar @@ -0,0 +1,40 @@ +[windows] skip 'UNIX only' + +# test that chezmoi dump warns when the config file has not been generated +exec chezmoi dump +[umask:002] cmp stdout golden/dump-umask-002.json +[umask:022] cmp stdout golden/dump-umask-022.json +stderr 'config file template has changed' + +# test that chezmoi dump does not return an error when the config file template has been modified +exec chezmoi init +edit $CHEZMOICONFIGDIR${/}chezmoi.toml +exec chezmoi dump +[umask:002] cmp stdout golden/dump-umask-002.json +[umask:022] cmp stdout golden/dump-umask-022.json +! stderr . + +-- golden/dump-umask-002.json -- +{ + ".file": { + "type": "file", + "name": ".file", + "contents": "# contents of .file\n", + "perm": 436 + } +} +-- golden/dump-umask-022.json -- +{ + ".file": { + "type": "file", + "name": ".file", + "contents": "# contents of .file\n", + "perm": 420 + } +} +-- home/user/.file -- +# contents of .file +-- home/user/.local/share/chezmoi/.chezmoi.toml.tmpl -- +[data] +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue2132.txtar b/internal/cmd/testdata/scripts/issue2132.txtar new file mode 100644 index 00000000000..ba814eb617c --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2132.txtar @@ -0,0 +1,10 @@ +# test that chezmoi apply does not create a remove_ directory if it has no contents +exec chezmoi apply +! exists $HOME/.dir + +# test that running chezmoi apply a second time completes with no output +exec chezmoi apply --no-tty +! stdout . +! exists $HOME/.dir + +-- home/user/.local/share/chezmoi/remove_dot_dir/non_existent_file -- diff --git a/internal/cmd/testdata/scripts/issue2137.txtar b/internal/cmd/testdata/scripts/issue2137.txtar new file mode 100644 index 00000000000..6dc0310ff44 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2137.txtar @@ -0,0 +1,11 @@ +# test that chezmoi apply fails if .chezmoiversion requires a more recent version +! exec chezmoi apply +stderr 'source state requires chezmoi version 3\.0\.0 or later' + +# test that chezmoi init fails if .chezmoiversion requires a more recent version +! exec chezmoi init +stderr 'source state requires chezmoi version 3\.0\.0 or later' + +-- home/user/.local/share/chezmoi/.chezmoiversion -- +3.0.0 +-- home/user/.local/share/chezmoi/.git/.keep -- diff --git a/internal/cmd/testdata/scripts/issue2177.txtar b/internal/cmd/testdata/scripts/issue2177.txtar new file mode 100644 index 00000000000..a5a6d74e613 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2177.txtar @@ -0,0 +1,8 @@ +# test that chezmoi forget prints a warning when asked to forget externals +exec chezmoi forget $HOME${/}.external +stderr 'cannot forget entry from external https://github\.com/user/repo\.git defined in .*/home/user/\.local/share/chezmoi/\.chezmoiexternal\.toml' + +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".external"] + type = "git-repo" + url = "https://github.com/user/repo.git" diff --git a/internal/cmd/testdata/scripts/issue2283.txtar b/internal/cmd/testdata/scripts/issue2283.txtar new file mode 100644 index 00000000000..7dc588da076 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2283.txtar @@ -0,0 +1,10 @@ +# test that sourceDir has the correct value in chezmoi init with .chezmoiroot +exec chezmoi init +cmpenv $CHEZMOICONFIGDIR/chezmoi.yaml golden/chezmoi.yaml + +-- golden/chezmoi.yaml -- +sourceDir: $CHEZMOISOURCEDIR/home +-- home/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home/user/.local/share/chezmoi/home/.chezmoi.yaml.tmpl -- +sourceDir: {{ .chezmoi.sourceDir }} diff --git a/internal/cmd/testdata/scripts/issue2300.txtar b/internal/cmd/testdata/scripts/issue2300.txtar new file mode 100644 index 00000000000..bde676dddfc --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2300.txtar @@ -0,0 +1,13 @@ +[windows] skip 'UNIX only' # FIXME + +# test that chezmoi edit --apply applies changes to a directory +exec chezmoi edit --apply $HOME/.dir +grep '# edited' $HOME/.dir/file + +-- home/user/.config/chezmoi/chezmoi.toml -- +[edit] + minDuration = 0 +-- home/user/.dir/file -- +# contents of .dir/file +-- home/user/.local/share/chezmoi/dot_dir/file -- +# contents of .dir/file diff --git a/internal/cmd/testdata/scripts/issue2302.txtar b/internal/cmd/testdata/scripts/issue2302.txtar new file mode 100644 index 00000000000..8b346273987 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2302.txtar @@ -0,0 +1,8 @@ +# test that chezmoi does not panic if an external uses an absolute path +! exec chezmoi diff +stderr 'path is not relative' + +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +["/home/user/.dir"] + type = "archive" + url = "https://example.com/example.tar.gz" diff --git a/internal/cmd/testdata/scripts/issue2315.txtar b/internal/cmd/testdata/scripts/issue2315.txtar new file mode 100644 index 00000000000..a8889a44527 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2315.txtar @@ -0,0 +1,21 @@ +[windows] skip 'UNIX only' + +# Test that chezmoi init runs run_before_ scripts before executing templates +env PATH=$PATH:$HOME/bin +exec chezmoi init --apply +cmp $HOME/.file golden/.file + +-- golden/.file -- +output +-- golden/binary.sh -- +#!/bin/sh + +echo output +-- home/user/.local/share/chezmoi/dot_file.tmpl -- +{{ output "binary.sh" -}} +-- home/user/.local/share/chezmoi/run_before_install-binary.sh -- +#!/bin/sh + +cp $WORK/golden/binary.sh $HOME/bin +chmod a+x $HOME/bin/binary.sh +-- home/user/bin/.keep -- diff --git a/internal/cmd/testdata/scripts/issue2354.txtar b/internal/cmd/testdata/scripts/issue2354.txtar new file mode 100644 index 00000000000..c9ae77c44d5 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2354.txtar @@ -0,0 +1,6 @@ +# test that chezmoi source-path does not read the source state if not needed +exec chezmoi source-path +stdout ${CHEZMOISOURCEDIR@R} + +-- home/user/.local/share/chezmoi/.chezmoiexternal.json -- +invalid JSON file diff --git a/internal/cmd/testdata/scripts/issue2380.txtar b/internal/cmd/testdata/scripts/issue2380.txtar new file mode 100644 index 00000000000..b42d14d2cc7 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2380.txtar @@ -0,0 +1,6 @@ +# test that chezmoi source-path with no arguments respects .chezmoiroot +exec chezmoi source-path +stdout ${CHEZMOISOURCEDIR@R}/home + +-- home/user/.local/share/chezmoi/.chezmoiroot -- +home diff --git a/internal/cmd/testdata/scripts/issue2427.txtar b/internal/cmd/testdata/scripts/issue2427.txtar new file mode 100644 index 00000000000..b3452f78cf1 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2427.txtar @@ -0,0 +1,44 @@ +symlink archive/dir/symlink -> file +exec tar czf www/archive.tar.gz archive + +httpd www + +# test that chezmoi managed lists all targets +exec chezmoi managed +cmp stdout golden/managed + +# test that chezmoi managed --include=encrypted lists encrypted files only +exec chezmoi managed --include=encrypted +cmp stdout golden/managed-encrypted + +# test that chezmoi managed --include=externals lists external targets only +exec chezmoi managed --include=externals +cmp stdout golden/managed-externals + +-- archive/dir/file -- +# contents of dir/file +-- golden/managed -- +.dir +.dir/dir +.dir/dir/file +.dir/dir/symlink +.encrypted +.file +-- golden/managed-encrypted -- +.encrypted +-- golden/managed-externals -- +.dir +.dir/dir +.dir/dir/file +.dir/dir/symlink +-- home/user/.config/chezmoi/chezmoi.toml -- +encryption = "gpg" +-- home/user/.local/share/chezmoi/.chezmoiexternal.yaml -- +.dir: + type: archive + url: {{ env "HTTPD_URL" }}/archive.tar.gz + stripComponents: 1 +-- home/user/.local/share/chezmoi/dot_file -- +-- home/user/.local/share/chezmoi/encrypted_dot_encrypted.asc -- +-- www/.file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue2500.txtar b/internal/cmd/testdata/scripts/issue2500.txtar new file mode 100644 index 00000000000..daeebb91ec1 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2500.txtar @@ -0,0 +1,24 @@ +[unix] chmod 755 bin/git +[windows] skip 'UNIX only' + +# test that the error message returned when git fails to update an external includes the target path +exec chezmoi apply +! exec chezmoi apply --force --refresh-externals +stderr ${HOME@R}/\.dir:\x20exit\x20status\x201 + +-- bin/git -- +#!/bin/sh + +case "$1" in +clone) + mkdir -p $3/.git + echo "clone ok" + ;; +*) + echo "unknown command $*" + exit 1 +esac +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "git-repo" + url = "https://github.com/owner/repo" diff --git a/internal/cmd/testdata/scripts/issue2510.txtar b/internal/cmd/testdata/scripts/issue2510.txtar new file mode 100644 index 00000000000..21d0338c34e --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2510.txtar @@ -0,0 +1,50 @@ +# test that .chezmoiignore works with scripts +exec chezmoi diff +cmp stdout golden/diff + +chhome home2/user + +# test that .chezmoiignore works with scripts in .chezmoiscripts +exec chezmoi diff +cmp stdout golden/diff2 + +-- golden/diff -- +diff --git a/script-one.sh b/script-one.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..a5498fa48acd6f616d60604b94352958c5e967fc +--- /dev/null ++++ b/script-one.sh +@@ -0,0 +1,3 @@ ++#!/bin/sh ++ ++echo "script one" +-- golden/diff2 -- +diff --git a/.chezmoiscripts/script-one.sh b/.chezmoiscripts/script-one.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..a5498fa48acd6f616d60604b94352958c5e967fc +--- /dev/null ++++ b/.chezmoiscripts/script-one.sh +@@ -0,0 +1,3 @@ ++#!/bin/sh ++ ++echo "script one" +-- home/user/.local/share/chezmoi/.chezmoiignore -- +script-two.sh +-- home/user/.local/share/chezmoi/run_script-one.sh -- +#!/bin/sh + +echo "script one" +-- home/user/.local/share/chezmoi/run_script-two.sh -- +#!/bin/sh + +echo "script two" +-- home2/user/.local/share/chezmoi/.chezmoiignore -- +.chezmoiscripts/script-two.sh +-- home2/user/.local/share/chezmoi/.chezmoiscripts/run_script-one.sh -- +#!/bin/sh + +echo "script one" +-- home2/user/.local/share/chezmoi/.chezmoiscripts/run_script-two.sh -- +#!/bin/sh + +echo "script two" diff --git a/internal/cmd/testdata/scripts/issue2573.txtar b/internal/cmd/testdata/scripts/issue2573.txtar new file mode 100644 index 00000000000..b21ebdc34f4 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2573.txtar @@ -0,0 +1,17 @@ +[windows] skip 'UNIX only' +chmod 755 bin/diff + +# test that chezmoi apply --verbose with an external diff command and dirs excluded does not run the diff command when a directory is removed +exec chezmoi apply --verbose +! stdout diff + +-- bin/diff -- +#!/bin/sh + +echo diff $* +-- home/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "diff" + exclude = ["dirs"] +-- home/user/.dir/subdir/.keep -- +-- home/user/.local/share/chezmoi/exact_dot_dir/.keep -- diff --git a/internal/cmd/testdata/scripts/issue2577.txtar b/internal/cmd/testdata/scripts/issue2577.txtar new file mode 100644 index 00000000000..758f19c1081 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2577.txtar @@ -0,0 +1,27 @@ +[windows] skip 'UNIX only' +chmod 755 bin/fossil + +# test that chezmoi update runs a custom update command and applies changes +exec chezmoi update +cmp $HOME/.file golden/.file + +-- bin/fossil -- +#!/bin/sh + +case "$*" in +"update") + echo "# contents of .file" > dot_file + ;; +*) + echo fossil: unknown command: $* + echo fossil: use "help" for more information + exit 1 + ;; +esac +-- golden/.file -- +# contents of .file +-- home/user/.config/chezmoi/chezmoi.toml -- +[update] + command = "fossil" + args = ["update"] +-- home/user/.local/share/chezmoi/.keep -- diff --git a/internal/cmd/testdata/scripts/issue2597.txtar b/internal/cmd/testdata/scripts/issue2597.txtar new file mode 100644 index 00000000000..c89ba35c276 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2597.txtar @@ -0,0 +1,20 @@ +exec tar czf www/master.tar.gz master + +httpd www + +exec chezmoi apply +exists $HOME/.oh-my-zsh/README.md +! exists $HOME/.oh-my-zsh/cache/.gitkeep + +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml.tmpl -- +[".oh-my-zsh"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/master.tar.gz" + exact = true + stripComponents = 1 +-- home/user/.local/share/chezmoi/.chezmoiignore -- +.oh-my-zsh/cache +-- master/README.md -- +# contents of README.md +-- master/cache/.gitkeep -- +-- www/.keep -- diff --git a/internal/cmd/testdata/scripts/issue2599.txtar b/internal/cmd/testdata/scripts/issue2599.txtar new file mode 100644 index 00000000000..97f1fde5bf0 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2599.txtar @@ -0,0 +1,4 @@ +# test that chezmoi apply --interactive --verbose does not prompt for empty scripts +exec chezmoi apply --interactive --no-tty --verbose + +-- home/user/.local/share/chezmoi/run_empty.sh -- diff --git a/internal/cmd/testdata/scripts/issue2609.txtar b/internal/cmd/testdata/scripts/issue2609.txtar new file mode 100644 index 00000000000..f356e45ff84 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2609.txtar @@ -0,0 +1,10 @@ +[windows] skip 'UNIX only' + +# test that chezmoi apply uses the umask from the configuration file +exec chezmoi apply +cmpmod 600 $HOME/.file + +-- home/user/.config/chezmoi/chezmoi.yaml -- +umask: 0o077 +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue2628.txtar b/internal/cmd/testdata/scripts/issue2628.txtar new file mode 100644 index 00000000000..8de6551e177 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2628.txtar @@ -0,0 +1,34 @@ +httpd www + +# test that .chezmoiexternal.toml.tmpl is read +exec chezmoi apply +cmp $HOME/.file golden/.file + +chhome home2/user + +# test that .chezmoiignore.tmpl is read +exec chezmoi apply +! exists $HOME/.file + +chhome home3/user + +# test that .chezmoiremove.tmpl is read +exec chezmoi apply +! exists $HOME/.file + +-- golden/.file -- +# contents of .file +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml.tmpl -- +[".file"] + type = "file" + url = "{{ env "HTTPD_URL" }}/.file" +-- home2/user/.local/share/chezmoi/.chezmoiignore.tmpl -- +.file +-- home2/user/.local/share/chezmoi/dot_file -- +# contents of .file +-- home3/user/.file -- +# contents of .file +-- home3/user/.local/share/chezmoi/.chezmoiremove.tmpl -- +.file +-- www/.file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue2649.txtar b/internal/cmd/testdata/scripts/issue2649.txtar new file mode 100644 index 00000000000..1c077fa1d97 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2649.txtar @@ -0,0 +1,11 @@ +[!lookup:github.com] skip 'github.com not found' + +# test that chezmoi init clones a public dotfiles repo if git is installed +[exec:git] exec chezmoi init --use-builtin-git=false chezmoi +[exec:git] exists ${CHEZMOISOURCEDIR}/README.md + +chhome home2/user + +# test that chezmoi init clones a public dotfiles repo using builtin git +exec chezmoi init --use-builtin-git=true chezmoi +exists ${CHEZMOISOURCEDIR}/README.md diff --git a/internal/cmd/testdata/scripts/issue2695.txtar b/internal/cmd/testdata/scripts/issue2695.txtar new file mode 100644 index 00000000000..8f5a498d614 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2695.txtar @@ -0,0 +1,40 @@ +# test that chezmoi status returns an error when the JSON config file is invalid +! exec chezmoi status +stderr 'invalid config' +! stderr 'json.*json' + +# check that chezmoi doctor warns about invalid JSON config files +! exec chezmoi doctor +stdout 'error\s+config-file\s+.*invalid character' + +chhome home2/user + +# test that chezmoi status returns an error when the TOML config file is invalid +! exec chezmoi status +stderr 'invalid config' +! stderr 'chezmoi\.toml.*chezmoi\.toml' + +# check that chezmoi doctor warns about invalid TOML config files +! exec chezmoi doctor +stdout 'error\s+config-file\s+.*incomplete number' + +chhome home3/user + +# test that chezmoi status returns an error when the YAML config file is invalid +! exec chezmoi status +stderr 'invalid config' +! stderr 'chezmoi\.yaml.*chezmoi\.yaml' + +# check that chezmoi doctor warns about invalid YAML config files +! exec chezmoi doctor +stdout 'error\s+config-file\s+.*unmarshal errors' + +-- home/user/.config/chezmoi/chezmoi.json -- +{ + "string": unquoted +} +-- home2/user/.config/chezmoi/chezmoi.toml -- +[example] + string = unquoted +-- home3/user/.config/chezmoi/chezmoi.yaml -- +string diff --git a/internal/cmd/testdata/scripts/issue2752.txtar b/internal/cmd/testdata/scripts/issue2752.txtar new file mode 100644 index 00000000000..99fc0ec55e4 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2752.txtar @@ -0,0 +1,10 @@ +[windows] skip 'uses forward slashes in paths' + +# test that chezmoi target-path does not include .chezmoiroot in the output +exec chezmoi target-path $CHEZMOISOURCEDIR${/}home${/}dot_file +stdout ${HOME@R}/\.file + +-- home/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home/user/.local/share/chezmoi/home/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue2820.txtar b/internal/cmd/testdata/scripts/issue2820.txtar new file mode 100644 index 00000000000..b95a09e9767 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2820.txtar @@ -0,0 +1,22 @@ +# test that chezmoi add refuses to add files in chezmoi's source directory +! exec chezmoi add $CHEZMOISOURCEDIR +stderr 'cannot add chezmoi file to chezmoi' + +# test that chezmoi add refuses to add chezmoi's config file +! exec chezmoi add $CHEZMOICONFIGDIR/chezmoi.toml +stderr 'cannot add chezmoi\x27s config file to chezmoi' + +# test that chezmoi add refuses to add files in chezmoi's cache directory +! exec chezmoi add $HOME/.cache/chezmoi +stderr 'cannot add chezmoi file to chezmoi' + +# test that chezmoi add refuses to add files in chezmoi's source directory when already in that directory +cd $CHEZMOISOURCEDIR +exists dot_file +! exec chezmoi add dot_file +stderr 'cannot add chezmoi file to chezmoi' + +-- home/user/.cache/chezmoi/.keep -- +-- home/user/.config/chezmoi/chezmoi.toml -- +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue2858.txtar b/internal/cmd/testdata/scripts/issue2858.txtar new file mode 100644 index 00000000000..4a23b055bd2 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2858.txtar @@ -0,0 +1,20 @@ +# test that chezmoi managed lists managed files in git-repo externals +exec chezmoi managed +cmp stdout golden/managed + +# test that chezmoi unmanaged does not list files in git-repo externals +exec chezmoi unmanaged +cmp stdout golden/unmanaged + +-- golden/managed -- +.dir +-- golden/unmanaged -- +.local +-- home/user/.dir/.git/HEAD -- +ref: refs/head/master +-- home/user/.dir/file -- +# contents of .dir/file +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "git-repo" + url = "https://example.com/user/repo" diff --git a/internal/cmd/testdata/scripts/issue2861.txtar b/internal/cmd/testdata/scripts/issue2861.txtar new file mode 100644 index 00000000000..fb473174674 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2861.txtar @@ -0,0 +1,12 @@ +# test that chezmoi status shows just-added-then-modified files +exec chezmoi add $HOME${/}.file +edit $HOME/.file +edit $CHEZMOISOURCEDIR/dot_file +edit $CHEZMOISOURCEDIR/dot_file +exec chezmoi status +cmp stdout golden/status + +-- golden/status -- +MM .file +-- home/user/.file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue2865.txtar b/internal/cmd/testdata/scripts/issue2865.txtar new file mode 100644 index 00000000000..760bcec7ae0 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2865.txtar @@ -0,0 +1,40 @@ +[!exec:git] skip 'git not found in $PATH' + +mkgitconfig + +# create a commit +exec chezmoi git init +exec chezmoi git add . +exec chezmoi git commit -- --message 'Initial commit' + +chhome home2/user + +# test that .chezmoi.sourceDir is set correctly when .chezmoiroot is present (chezmoi init --apply in a clean home directory) +mkgitconfig +exec chezmoi init --apply file://$WORK/home/user/.local/share/chezmoi +stdout '\.chezmoi\.sourceDir=.*/\.local/share/chezmoi/home$' +stdout 'CHEZMOI_SOURCE_DIR=.*/\.local/share/chezmoi/home$' + +# test that .chezmoi.sourceDir is set correctly in config file +exec chezmoi execute-template '{{ .testDir }}' +stdout '/.local/share/chezmoi/home$' + +-- home/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home/user/.local/share/chezmoi/home/.chezmoi.toml.tmpl -- +[data] + testDir = {{ .chezmoi.sourceDir | quote }} +-- home/user/.local/share/chezmoi/home/.chezmoiignore -- +{{- if eq .chezmoi.os "windows" }} +.chezmoiscripts/echo.sh +{{- else }} +.chezmoiscripts/echo.ps1 +{{- end }} +-- home/user/.local/share/chezmoi/home/.chezmoiscripts/run_once_echo.ps1.tmpl -- +Write-Host .chezmoi.sourceDir={{ .chezmoi.sourceDir }} +Write-Host CHEZMOI_SOURCE_DIR=$Env:CHEZMOI_SOURCE_DIR +-- home/user/.local/share/chezmoi/home/.chezmoiscripts/run_once_echo.sh.tmpl -- +#!/bin/sh + +echo .chezmoi.sourceDir={{ .chezmoi.sourceDir }} +echo CHEZMOI_SOURCE_DIR=${CHEZMOI_SOURCE_DIR} diff --git a/internal/cmd/testdata/scripts/issue2934.txtar b/internal/cmd/testdata/scripts/issue2934.txtar new file mode 100644 index 00000000000..c5b6a77737b --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2934.txtar @@ -0,0 +1,24 @@ +[windows] skip 'UNIX only' + +# test that chezmoi sets environment variables for modify_ scripts +exec chezmoi apply $HOME${/}.modify +grep ^CHEZMOI_SOURCE_DIR=${CHEZMOISOURCEDIR@R}$ $HOME/.modify +grep ^CHEZMOI_SOURCE_FILE=modify_dot_modify$ $HOME/.modify + +chhome home2/user + +# test that CHEZMOI_SOURCE_FILE environment variable is set when running scripts +exec chezmoi apply $HOME${/}script.sh +stdout ^CHEZMOI_SOURCE_DIR=${CHEZMOISOURCEDIR@R}$ +stdout ^CHEZMOI_SOURCE_FILE=run_script.sh$ + +-- home/user/.local/share/chezmoi/modify_dot_modify -- +#!/bin/sh + +echo CHEZMOI_SOURCE_DIR=${CHEZMOI_SOURCE_DIR} +echo CHEZMOI_SOURCE_FILE=${CHEZMOI_SOURCE_FILE} +-- home2/user/.local/share/chezmoi/run_script.sh -- +#!/bin/sh + +echo CHEZMOI_SOURCE_DIR=${CHEZMOI_SOURCE_DIR} +echo CHEZMOI_SOURCE_FILE=${CHEZMOI_SOURCE_FILE} diff --git a/internal/cmd/testdata/scripts/issue2937.txtar b/internal/cmd/testdata/scripts/issue2937.txtar new file mode 100644 index 00000000000..31e21bbe10d --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2937.txtar @@ -0,0 +1,24 @@ +# test that .chezmoiignore prevents git-repo externals from being downloaded +exec chezmoi apply +! exists .dir + +chhome home2/user + +# test that .chezmoiignore prevents external archives from being downloaded +exec chezmoi apply +! exists .dir + +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "archive" + url = "https://example.com/does-not-exist.tar.gz" +-- home/user/.local/share/chezmoi/.chezmoiignore -- +.dir +.dir/** +-- home2/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "git-repo" + url = "https://example.com/user/repo.git" +-- home2/user/.local/share/chezmoi/.chezmoiignore -- +.dir +.dir/** diff --git a/internal/cmd/testdata/scripts/issue2942.txtar b/internal/cmd/testdata/scripts/issue2942.txtar new file mode 100644 index 00000000000..80689ef81e3 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2942.txtar @@ -0,0 +1,3 @@ +# test that .chezmoi.config variables are capitalized correctly +exec chezmoi execute-template '{{ .chezmoi.config.keepassxc.command }}' +stdout '^keepassxc-cli$' diff --git a/internal/cmd/testdata/scripts/issue2954.txtar b/internal/cmd/testdata/scripts/issue2954.txtar new file mode 100644 index 00000000000..fa4e2dfa1be --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2954.txtar @@ -0,0 +1,7 @@ +# test that chezmoi destroy does not delete the source directory when removing a file in an exact directory +mkdir $HOME/.dir +mkfile $HOME/.dir/test.file +exec chezmoi destroy --force $HOME${/}.dir/test.file +exists $CHEZMOISOURCEDIR + +-- home/user/.local/share/chezmoi/exact_dot_dir/.keep -- diff --git a/internal/cmd/testdata/scripts/issue2964.txtar b/internal/cmd/testdata/scripts/issue2964.txtar new file mode 100644 index 00000000000..2eacf91f6cb --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2964.txtar @@ -0,0 +1,11 @@ +[windows] skip 'UNIX only' + +chmod 755 $CHEZMOISOURCEDIR/external_dot_dir/executable + +# test that external_ directories respect the executable bit +exec chezmoi apply +[umask:002] cmpmod 775 $HOME/.dir/executable +[umask:022] cmpmod 755 $HOME/.dir/executable + +-- home/user/.local/share/chezmoi/external_dot_dir/executable -- +# contents of .dir/executable diff --git a/internal/cmd/testdata/scripts/issue2977.txtar b/internal/cmd/testdata/scripts/issue2977.txtar new file mode 100644 index 00000000000..09b1073d5b8 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2977.txtar @@ -0,0 +1,7 @@ +# test that chezmoi source-path returns an error for file to be removed from exact directories +mkdir $HOME/.dir +mkfile $HOME/.dir/test.file +! exec chezmoi source-path $HOME/.dir/test.file +stderr 'not in source state' + +-- home/user/.local/share/chezmoi/exact_dot_dir/.keep -- diff --git a/internal/cmd/testdata/scripts/issue2995.txtar b/internal/cmd/testdata/scripts/issue2995.txtar new file mode 100644 index 00000000000..8d24c3ca446 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue2995.txtar @@ -0,0 +1,17 @@ +# test that chezmoi apply only creates empty files if they have the empty_ attribute +exec chezmoi apply +! exists $HOME/new.txt +exists $HOME/create-new.txt +cmp $HOME/existing.txt golden/existing.txt +cmp $HOME/existing-empty.txt golden/existing-empty.txt + +-- golden/existing-empty.txt -- +-- golden/existing.txt -- +# contents of existing.txt +-- home/user/.local/share/chezmoi/create_empty_create-new.txt -- +-- home/user/.local/share/chezmoi/create_empty_existing-empty.txt -- +-- home/user/.local/share/chezmoi/create_existing.txt -- +-- home/user/.local/share/chezmoi/create_new.txt -- +-- home/user/existing-empty.txt -- +-- home/user/existing.txt -- +# contents of existing.txt diff --git a/internal/cmd/testdata/scripts/issue3005.txtar b/internal/cmd/testdata/scripts/issue3005.txtar new file mode 100644 index 00000000000..cd58bba0dca --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3005.txtar @@ -0,0 +1,2 @@ +# test that chezmoi --debug init does not fail +exec chezmoi --debug init diff --git a/internal/cmd/testdata/scripts/issue3008.txtar b/internal/cmd/testdata/scripts/issue3008.txtar new file mode 100644 index 00000000000..e11ad3d5fb0 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3008.txtar @@ -0,0 +1,7 @@ +# test that chezmoi apply does not get confused by multiple files in .chezmoidata +exec chezmoi apply + +-- home/user/.local/share/chezmoi/.chezmoidata/a.yaml -- +a: 1 +-- home/user/.local/share/chezmoi/.chezmoidata/b.yaml -- +b: 2 diff --git a/internal/cmd/testdata/scripts/issue3051.txtar b/internal/cmd/testdata/scripts/issue3051.txtar new file mode 100644 index 00000000000..c7012481477 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3051.txtar @@ -0,0 +1,9 @@ +# test that chezmoi add respects .chezmoiignore in the presence of protected paths +exec chezmoi add -r $HOME${/}.local +exists $CHEZMOISOURCEDIR/dot_local/bin/hello.sh + +-- home/user/.local/bin/hello.sh -- +#!/bin/sh +-- home/user/.local/share/chezmoi/.chezmoiignore -- +.local/share/chezmoi +.local/share/chezmoi/** diff --git a/internal/cmd/testdata/scripts/issue3064.txtar b/internal/cmd/testdata/scripts/issue3064.txtar new file mode 100644 index 00000000000..877d89b64eb --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3064.txtar @@ -0,0 +1,10 @@ +[windows] skip 'UNIX only' + +# test that chezmoi respects the persistentState config variable +exec chezmoi apply +exists test.boltdb + +-- home/user/.config/chezmoi/chezmoi.yaml -- +persistentState: test.boltdb +-- home/user/.local/share/chezmoi/run_once_script.sh -- +#!/bin/sh diff --git a/internal/cmd/testdata/scripts/issue3113.txtar b/internal/cmd/testdata/scripts/issue3113.txtar new file mode 100644 index 00000000000..0c563d07ce1 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3113.txtar @@ -0,0 +1,44 @@ +[windows] skip 'UNIX only' + +chmod 777 bin/custom-pager +chmod 777 bin/default-pager + +# test that chezmoi diff uses the default pager by default +exec chezmoi diff +stdout default-pager + +chhome home2/user + +# test that setting diff.pager to a custom pager uses that pager +exec chezmoi diff +stdout custom-pager + +chhome home3/user + +# test that setting diff.pager to the empty string disables the pager +exec chezmoi diff +stdout diff + +-- bin/custom-pager -- +#!/bin/sh + +echo custom-pager +-- bin/default-pager -- +#!/bin/sh + +echo default-pager +-- home/user/.config/chezmoi/chezmoi.yaml -- +pager: default-pager +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file +-- home2/user/.config/chezmoi/chezmoi.yaml -- +diff: + pager: custom-pager +-- home2/user/.local/share/chezmoi/dot_file -- +# contents of .file +-- home3/user/.config/chezmoi/chezmoi.yaml -- +pager: default-pager +diff: + pager: '' +-- home3/user/.local/share/chezmoi/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue3126.txtar b/internal/cmd/testdata/scripts/issue3126.txtar new file mode 100644 index 00000000000..4936973af8b --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3126.txtar @@ -0,0 +1,9 @@ +# test that template executions are independent +exec chezmoi apply +! grep bar $HOME/file1 +! grep foo $HOME/file2 + +-- home/user/.local/share/chezmoi/file1.tmpl -- +{{ merge . (dict "key1" "foo") }} +-- home/user/.local/share/chezmoi/file2.tmpl -- +{{ merge . (dict "key2" "bar") }} diff --git a/internal/cmd/testdata/scripts/issue3127.txtar b/internal/cmd/testdata/scripts/issue3127.txtar new file mode 100644 index 00000000000..360bd8f1ef8 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3127.txtar @@ -0,0 +1,27 @@ +mkdir $CHEZMOISOURCEDIR + +# test that chezmoi --config=path init --config-path=path writes the initial config into path +cp golden/config1.toml $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl +exec chezmoi --config=$HOME/config/athome.toml init --config-path=$HOME/config/athome.toml +cmp $HOME/config/athome.toml golden/config1.toml + +# test that chezmoi --config=path init --config-path=path writes an updated config into path +cp golden/config2.toml $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl +exec chezmoi --config=$HOME/config/athome.toml init --config-path=$HOME/config/athome.toml +cmp $HOME/config/athome.toml golden/config2.toml + +# test that chezmoi --config=path init writes an updated config into path +cp golden/config3.toml $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl +exec chezmoi --config=$HOME/config/athome.toml init +cmp $HOME/config/athome.toml golden/config3.toml + + +-- golden/config1.toml -- +[data] + email = "mail1@example.com" +-- golden/config2.toml -- +[data] + email = "mail2@example.com" +-- golden/config3.toml -- +[data] + email = "mail3@example.com" diff --git a/internal/cmd/testdata/scripts/issue3163.txtar b/internal/cmd/testdata/scripts/issue3163.txtar new file mode 100644 index 00000000000..11f2b8941dd --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3163.txtar @@ -0,0 +1,17 @@ +# test that chezmoi supports separate files via templates +exec chezmoi apply +cmp $HOME/.file1 golden/file1 +cmp $HOME/.file2 golden/file2 + +-- golden/file1 -- +# contents of .file1 +-- golden/file2 -- +# contents of .file2 +-- home/user/.local/share/chezmoi/.chezmoitemplates/file2.tmpl -- +# {{ . }} +-- home/user/.local/share/chezmoi/.file1 -- +# contents of .file1 +-- home/user/.local/share/chezmoi/dot_file1.tmpl -- +{{- include ".file1" -}} +-- home/user/.local/share/chezmoi/dot_file2.tmpl -- +{{- template "file2.tmpl" "contents of .file2" -}} diff --git a/internal/cmd/testdata/scripts/issue3206.txtar b/internal/cmd/testdata/scripts/issue3206.txtar new file mode 100644 index 00000000000..0352a4bf1e1 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3206.txtar @@ -0,0 +1,96 @@ +# test that chezmoi apply sees .chezmoidata files in a subdirectory when a .chezmoiignore file is present +exec chezmoi apply --debug +cmp $HOME/.config/expanso/match/greek.yml golden/greek.yml + +-- golden/greek.yml -- +# `propagate_case` allows e.g.: +# alpha^ => α +# ALPHA^ => Α [or Alpha^ since it's just 1 char] + +matches: + - trigger: "alpha^" + replace: "α" + propagate_case: true + - trigger: "beta^" + replace: "β" + propagate_case: true + - trigger: "chi^" + replace: "χ" + propagate_case: true + - trigger: "delta^" + replace: "δ" + propagate_case: true + - trigger: "epsilon^" + replace: "ε" + propagate_case: true + - trigger: "eta^" + replace: "η" + propagate_case: true + - trigger: "gamma^" + replace: "γ" + propagate_case: true + - trigger: "iota^" + replace: "ι" + propagate_case: true + - trigger: "kappa^" + replace: "κ" + propagate_case: true + - trigger: "lambda^" + replace: "λ" + propagate_case: true + - trigger: "mu^" + replace: "μ" + propagate_case: true + - trigger: "nu^" + replace: "ν" + propagate_case: true + - trigger: "omega^" + replace: "ω" + propagate_case: true + - trigger: "omicron^" + replace: "ο" + propagate_case: true + - trigger: "phi^" + replace: "φ" + propagate_case: true + - trigger: "pi^" + replace: "π" + propagate_case: true + - trigger: "psi^" + replace: "ψ" + propagate_case: true + - trigger: "rho^" + replace: "ρ" + propagate_case: true + - trigger: "sigma^" + replace: "σ" + propagate_case: true + - trigger: "tau^" + replace: "τ" + propagate_case: true + - trigger: "theta^" + replace: "θ" + propagate_case: true + - trigger: "upsilon^" + replace: "υ" + propagate_case: true + - trigger: "xi^" + replace: "ξ" + propagate_case: true + - trigger: "zeta^" + replace: "ζ" + propagate_case: true +-- home/user/.local/share/chezmoi/.chezmoiignore -- +-- home/user/.local/share/chezmoi/dot_config/private_expanso/match/.chezmoidata.yaml -- +greek_alphabet: {alpha: α, beta: β, chi: χ, delta: δ, epsilon: ε, eta: η, gamma: γ, iota: ι, kappa: κ, lambda: λ, mu: μ, nu: ν, omega: ω, omicron: ο, phi: φ, pi: π, psi: ψ, rho: ρ, sigma: σ, tau: τ, theta: θ, upsilon: υ, xi: ξ, zeta: ζ} +-- home/user/.local/share/chezmoi/dot_config/private_expanso/match/greek.yml.tmpl -- +# `propagate_case` allows e.g.: +# alpha^ => α +# ALPHA^ => Α [or Alpha^ since it's just 1 char] + +matches: +{{- range $trigger, $replace := .greek_alphabet }} + - trigger: "{{ $trigger }}^" + replace: "{{ $replace }}" + propagate_case: true +{{- end }} diff --git a/internal/cmd/testdata/scripts/issue3232.txtar b/internal/cmd/testdata/scripts/issue3232.txtar new file mode 100644 index 00000000000..1f1baea94fb --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3232.txtar @@ -0,0 +1,41 @@ +exec tar czf www/archive1.tar.gz archive1 +exec tar czf www/archive2.tar.gz archive2 + +httpd www + +# test that chezmoi externals allow multiple non-conflicting archives +exec chezmoi apply +cmp $HOME/.dir/file1 archive1/file1 +cmp $HOME/.dir/file2 archive2/file2 + +chhome home2/user + +# test that chezmoi externals do not allow multiple conflicting archives +! exec chezmoi apply +stderr 'inconsistent state' + +-- archive1/file1 -- +# contents of file1 +-- archive2/file2 -- +# contents of file2 +-- home/user/.local/share/chezmoi/.chezmoiexternals/archive1.toml.tmpl -- +[".dir"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive1.tar.gz" + stripComponents = 1 +-- home/user/.local/share/chezmoi/.chezmoiexternals/archive2.toml.tmpl -- +[".dir"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive2.tar.gz" + stripComponents = 1 +-- home2/user/.local/share/chezmoi/.chezmoiexternals/archive1.toml.tmpl -- +[".dir"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive1.tar.gz" + stripComponents = 1 +-- home2/user/.local/share/chezmoi/.chezmoiexternals/archive2.toml.tmpl -- +[".dir"] + type = "archive" + url = "{{ env "HTTPD_URL" }}/archive1.tar.gz" + stripComponents = 1 +-- www/.keep -- diff --git a/internal/cmd/testdata/scripts/issue3240.txtar b/internal/cmd/testdata/scripts/issue3240.txtar new file mode 100644 index 00000000000..ea7bd5e1eba --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3240.txtar @@ -0,0 +1,26 @@ +[windows] skip 'UNIX only' + +chmod 777 bin/custom-pager + +# test that chezmoi diff uses the custom pager if set +exec chezmoi diff +cmp stdout golden/stdout + +# test that chezmoi diff --verbose has identical output +exec chezmoi diff --verbose +cmp stdout golden/stdout + +# test that chezmoi apply uses the custom pager if set +exec chezmoi apply --dry-run --verbose +stdout custom-pager + +-- bin/custom-pager -- +#!/bin/sh + +echo custom-pager +-- golden/stdout -- +custom-pager +-- home/user/.config/chezmoi/chezmoi.toml -- +pager = "custom-pager" +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue3257.txtar b/internal/cmd/testdata/scripts/issue3257.txtar new file mode 100644 index 00000000000..a0baeec2278 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3257.txtar @@ -0,0 +1,26 @@ +[windows] skip 'UNIX only' + +chmod 755 bin/custom-pager + +# test that chezmoi add invokes the pager when verbose is set +exec chezmoi add $HOME${/}.file +stdout custom-pager + +# test chat chezmoi chattr invokes the pager when verbose is set +exec chezmoi chattr +private $HOME${/}.file +stdout custom-pager + +# test that chezmoi status does not invoke the pager when verbose is set +exec chezmoi status +! stdout custom-pager + +-- bin/custom-pager -- +#!/bin/sh + +echo custom-pager +-- home/user/.config/chezmoi/chezmoi.yaml -- +pager: custom-pager +verbose: true +-- home/user/.file -- +# contents of .file +-- home/user/.local/share/chezmoi/.keep -- diff --git a/internal/cmd/testdata/scripts/issue3268.txtar b/internal/cmd/testdata/scripts/issue3268.txtar new file mode 100644 index 00000000000..d0f7eed1709 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3268.txtar @@ -0,0 +1,35 @@ +[unix] chmod 755 bin/chezmoi-test-command + +# test that chezmoi sets CHEZMOI_ environment variables +exec chezmoi execute-template '{{ output "chezmoi-test-command" }}' +stdout 'CHEZMOI_SOURCE_DIR=.*/\.local/share/chezmoi\s?$' + +chhome home2/user + +# test that chezmoi sets environment variables from env +exec chezmoi execute-template '{{ env "VAR" }}' +stdout VALUE + +chhome home3/user + +# test that env and scriptEnv cannot both be set +! exec chezmoi execute-template '' +stderr 'only one of env or scriptEnv may be set' + +-- bin/chezmoi-test-command -- +#!/bin/sh + +echo CHEZMOI_SOURCE_DIR=${CHEZMOI_SOURCE_DIR} +-- bin/chezmoi-test-command.cmd -- +@echo CHEZMOI_SOURCE_DIR=%CHEZMOI_SOURCE_DIR% +-- home2/user/.config/chezmoi/chezmoi.json -- +{ + "env": { + "VAR": "VALUE" + } +} +-- home3/user/.config/chezmoi/chezmoi.yaml -- +env: + VAR: VALUE +scriptEnv: + VAR: VALUE diff --git a/internal/cmd/testdata/scripts/issue3325.txtar b/internal/cmd/testdata/scripts/issue3325.txtar new file mode 100644 index 00000000000..2d7ad9c7b9d --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3325.txtar @@ -0,0 +1,52 @@ +# test that integer types are preserved in fromJson | toToml template function pipelines +exec chezmoi execute-template '{{ "{\"key\":1}" | fromJson | toToml }}' +cmp stdout golden/stdout + +# test that integer types are preserved in the .data section of JSONC config files +exec chezmoi execute-template '{{ .data | toToml }}' +cmp stdout golden/config.toml + +# test that integer and floating point types are preserved from .chezmoidata.json files +exec chezmoi execute-template '{{ .json | toToml }}' +cmp stdout golden/json.toml + +# test that integer and floating point types are preserved from .chezmoidata.jsonc files +exec chezmoi execute-template '{{ .jsonc | toToml }}' +cmp stdout golden/jsonc.toml + +-- golden/config.toml -- +dataFloat64 = 1.1 +dataInt64 = 2 +-- golden/json.toml -- +jsonFloat64 = 3.3 +jsonInt64 = 4 +-- golden/jsonc.toml -- +jsoncFloat64 = 5.5 +jsoncInt64 = 6 +-- golden/stdout -- +key = 1 +-- home/user/.config/chezmoi/chezmoi.jsonc -- +{ + // Comment + "data": { + "data": { + "dataFloat64": 1.1, + "dataInt64": 2, + } + } +} +-- home/user/.local/share/chezmoi/.chezmoidata.json -- +{ + "json": { + "jsonFloat64": 3.3, + "jsonInt64": 4 + } +} +-- home/user/.local/share/chezmoi/.chezmoidata.jsonc -- +{ + // Comment + "jsonc": { + "jsoncFloat64": 5.5, + "jsoncInt64": 6, + } +} diff --git a/internal/cmd/testdata/scripts/issue3349.txtar b/internal/cmd/testdata/scripts/issue3349.txtar new file mode 100644 index 00000000000..50bfb0eacd7 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3349.txtar @@ -0,0 +1,21 @@ +symlink $CHEZMOISOURCEDIR/home/dot_symlink_to_file -> dot_file +symlink $CHEZMOISOURCEDIR/home/dot_symlink_to_dir -> dot_dir + +# test that chezmoi apply follows symlinks to files but not symlinks to directories +exec chezmoi apply +cmp $HOME/.file golden/.file +cmp $HOME/.symlink_to_file golden/.file +cmp $HOME/.dir/file golden/.dir/file +isdir $HOME/.symlink_to_dir +! exists $HOME/.symlink_to_dir/file + +-- golden/.dir/file -- +# contents of .dir/file +-- golden/.file -- +# contents of .file +-- home/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home/user/.local/share/chezmoi/home/dot_dir/file -- +# contents of .dir/file +-- home/user/.local/share/chezmoi/home/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue3371.txtar b/internal/cmd/testdata/scripts/issue3371.txtar new file mode 100644 index 00000000000..5ee218558e0 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3371.txtar @@ -0,0 +1,24 @@ +[!exec:git] skip 'git not found in $PATH' + +[windows] unix2dos golden/.file + +mkgitconfig + +# create a dotfile repo with a config file template +exec chezmoi init +exec chezmoi git add . +exec chezmoi git commit -- --message 'Initial commit' + +chhome home2/user + +# test that chezmoi init --apply sets environment variables during apply from the config file +exec chezmoi init --apply file://$WORK/home/user/.local/share/chezmoi +cmp $HOME/.file golden/.file + +-- golden/.file -- +TEST_CONFIG_VAR=test_config_value +-- home/user/.local/share/chezmoi/.chezmoi.yaml.tmpl -- +env: + TEST_CONFIG_VAR: test_config_value +-- home/user/.local/share/chezmoi/dot_file.tmpl -- +TEST_CONFIG_VAR={{ env "TEST_CONFIG_VAR" }} diff --git a/internal/cmd/testdata/scripts/issue3374.txtar b/internal/cmd/testdata/scripts/issue3374.txtar new file mode 100644 index 00000000000..8bf342d59c3 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3374.txtar @@ -0,0 +1,8 @@ +# test that chezmoi copes with extra slashes in path arguments +exec chezmoi cat $HOME//.file +cmp stdout golden/.file + +-- golden/.file -- +# contents of .file +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue3414.txtar b/internal/cmd/testdata/scripts/issue3414.txtar new file mode 100644 index 00000000000..19ec526cfda --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3414.txtar @@ -0,0 +1,26 @@ +exec tar czf www/archive.tar.gz archive + +httpd www + +# test that running chezmoi apply twice does not complain about modified files +exec chezmoi apply +exec chezmoi apply --no-tty + +-- archive/shell/completion.bash -- +# contents of shell/completion.bash +-- archive/shell/key-bindings.bash -- +# contents of shell/key-bindings.bash +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".bashrc.d/additional/fzf/shell/completion.bash"] + type = "archive-file" + path = "shell/completion.bash" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + stripComponents = 1 +[".bashrc.d/additional/fzf/shell/key-bindings.bash"] + type = "archive-file" + path = "shell/key-bindings.bash" + url = "{{ env "HTTPD_URL" }}/archive.tar.gz" + stripComponents = 1 +-- home/user/.local/share/chezmoi/exact_dot_bashrc.d/file.sh -- +# contents of .bashrc.d/file.sh +-- www/.keep -- diff --git a/internal/cmd/testdata/scripts/issue3415.txtar b/internal/cmd/testdata/scripts/issue3415.txtar new file mode 100644 index 00000000000..1eb1ae204ce --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3415.txtar @@ -0,0 +1,26 @@ +# test that chezmoi apply does not remove remove_ directories if they are not empty +exec chezmoi apply +exists $HOME/.dir/file + +# test that chezmoi apply does remove remove_ directories if they are empty +rm $HOME/.dir/file +exec chezmoi apply +! exists $HOME/.dir + +chhome home2/user + +# test that chezmoi apply does not remove remove_ directories if they are not empty, recursively +exec chezmoi apply +exists $HOME/.dir/subdir/file + +# test that chezmoi apply does remove remove_ directories if they are empty, recursively +rm $HOME/.dir/subdir/file +exec chezmoi apply +! exists $HOME/.dir + +-- home/user/.dir/file -- +# contents of .dir/file +-- home/user/.local/share/chezmoi/remove_dot_dir/.keep -- +-- home2/user/.dir/subdir/file -- +# contents of .dir/subdir/file +-- home2/user/.local/share/chezmoi/remove_dot_dir/remove_subdir/.keep -- diff --git a/internal/cmd/testdata/scripts/issue3418.txtar b/internal/cmd/testdata/scripts/issue3418.txtar new file mode 100644 index 00000000000..400f64b4a02 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3418.txtar @@ -0,0 +1,14 @@ +# test that chezmoi execute-template --init does read .chezmoitemplates +stdin $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl +exec chezmoi execute-template +stdout 'contents of template' + +# test that chezmoi execute-template --init does not read .chezmoitemplates +stdin $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl +! exec chezmoi execute-template --init +! stdout 'contents of template' + +-- home/user/.local/share/chezmoi/.chezmoi.toml.tmpl -- +{{ template "template" }} +-- home/user/.local/share/chezmoi/.chezmoitemplates/template -- +# contents of template diff --git a/internal/cmd/testdata/scripts/issue3421.txtar b/internal/cmd/testdata/scripts/issue3421.txtar new file mode 100644 index 00000000000..a650148bae3 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3421.txtar @@ -0,0 +1,29 @@ +[windows] skip 'UNIX only' + +# test that chezmoi runs run_once_ and run_onchange_ scripts only once +exec chezmoi apply +stdout once +stdout onchange +exec chezmoi apply +! stdout . + +# test that chezmoi runs run_once_ scripts after chezmoi state delete-bucket --bucket=scriptState +exec chezmoi state delete-bucket --bucket=scriptState +exec chezmoi apply +stdout once +! stdout onchange + +# test that chezmoi runs run_once_ scripts after chezmoi state delete-bucket --bucket=entryState +exec chezmoi state delete-bucket --bucket=entryState +exec chezmoi apply +! stdout once +stdout onchange + +-- home/user/.local/share/chezmoi/run_once_once.sh -- +#!/bin/sh + +echo once +-- home/user/.local/share/chezmoi/run_onchange_onchange.sh -- +#!/bin/sh + +echo onchange diff --git a/internal/cmd/testdata/scripts/issue3510.txtar b/internal/cmd/testdata/scripts/issue3510.txtar new file mode 100644 index 00000000000..27d6bd4de71 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3510.txtar @@ -0,0 +1,21 @@ +[windows] skip 'UNIX only' + +expandenv $CHEZMOISOURCEDIR/.chezmoiexternal.toml + +# test that chezmoi apply does not cache the absence of git in $PATH at startup +exec chezmoi apply +stdout 'using newly-installed git' + +-- golden/git -- +#!/bin/sh + +echo "using newly-installed git" +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".dir"] + type = "git-repo" + url = "file://$WORK/repo" +-- home/user/.local/share/chezmoi/run_once_before_install-git.sh -- +#!/bin/sh + +mkdir -p $WORK/bin +install -m 755 $WORK/golden/git $WORK/bin diff --git a/internal/cmd/testdata/scripts/issue3525.txtar b/internal/cmd/testdata/scripts/issue3525.txtar new file mode 100644 index 00000000000..150dcc4d9c6 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3525.txtar @@ -0,0 +1,13 @@ +# test that chezmoi add does not add files in external_ directories +exec chezmoi add $HOME${/}.external/file +stderr '.external: skipping entries in external_ directory' + +# test that chezmoi add does not add files in subdirectories of external_ directories +exec chezmoi add $HOME${/}.external/dir/file +stderr '.external: skipping entries in external_ directory' + +-- home/user/.external/dir/file -- +# contents of .external/dir/file +-- home/user/.external/file -- +# contents of .external/file +-- home/user/.local/share/chezmoi/external_dot_external/.keep -- diff --git a/internal/cmd/testdata/scripts/issue3582.txtar b/internal/cmd/testdata/scripts/issue3582.txtar new file mode 100644 index 00000000000..56c3bac175c --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3582.txtar @@ -0,0 +1,13 @@ +# test that chezmoi data shows data read from TOML config files +exec chezmoi data --format=json +stdout '"mode": "file"' +stdout '"pager": "my-pager"' +stdout '"pager": "my-diff-pager"' +stdout '"identity": ".*/my-age-identity"' + +-- home/user/.config/chezmoi/chezmoi.toml -- +pager = "my-pager" +[diff] + pager = "my-diff-pager" +[age] + identity = "my-age-identity" diff --git a/internal/cmd/testdata/scripts/issue3590.txtar b/internal/cmd/testdata/scripts/issue3590.txtar new file mode 100644 index 00000000000..75a83cf807b --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3590.txtar @@ -0,0 +1,3 @@ +# test that chezmoi data does not include default sentinels +exec chezmoi data +! stdout '"\\u0000"' diff --git a/internal/cmd/testdata/scripts/issue3602.txtar b/internal/cmd/testdata/scripts/issue3602.txtar new file mode 100644 index 00000000000..1d1d7df77bd --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3602.txtar @@ -0,0 +1,2 @@ +# test that the .chezmoi.config.destDir template variable is compatible with the replace template function +exec chezmoi execute-template '{{ .chezmoi.config.destDir | replace "abc" "def "}}' diff --git a/internal/cmd/testdata/scripts/issue3630.txtar b/internal/cmd/testdata/scripts/issue3630.txtar new file mode 100644 index 00000000000..5ef9ffa8e1c --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3630.txtar @@ -0,0 +1,14 @@ +# test that chezmoi add does not traverse into ignored directories +chmod 000 $HOME/.dir/private +exec chezmoi add $HOME${/}.dir +stderr 'warning: ignoring .dir/private' +cmp $CHEZMOISOURCEDIR/dot_dir/public/file golden/file + +-- golden/file -- +# contents of .dir/public/file +-- home/user/.dir/private/file -- +# contents of .dir/private/file +-- home/user/.dir/public/file -- +# contents of .dir/public/file +-- home/user/.local/share/chezmoi/.chezmoiignore -- +.dir/private diff --git a/internal/cmd/testdata/scripts/issue3652.txtar b/internal/cmd/testdata/scripts/issue3652.txtar new file mode 100644 index 00000000000..5207e2c158c --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3652.txtar @@ -0,0 +1,12 @@ +# test that chezmoi add skips files in external_ directories +exec chezmoi apply +exists $HOME/.dir/submodule/.git/.keep +exec chezmoi add $HOME${/}.dir +stderr '.dir/submodule: skipping entries in external_ directory' +cmp $CHEZMOISOURCEDIR/dot_dir/file golden/file + +-- golden/file -- +# contents of file +-- home/user/.dir/file -- +# contents of file +-- home/user/.local/share/chezmoi/dot_dir/external_submodule/.git/.keep -- diff --git a/internal/cmd/testdata/scripts/issue3666.txtar b/internal/cmd/testdata/scripts/issue3666.txtar new file mode 100644 index 00000000000..deeae377d6d --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3666.txtar @@ -0,0 +1,7 @@ +# test that chezmoi add creates parent directories +exec chezmoi add $HOME${/}.config/helix/themes/ayu_custom.toml +exists $CHEZMOISOURCEDIR/dot_config/exact_helix/themes/ayu_custom.toml + +-- home/user/.config/helix/themes/ayu_custom.toml -- +# contents of ayu_custom.toml +-- home/user/.local/share/chezmoi/dot_config/exact_helix/.keep -- diff --git a/internal/cmd/testdata/scripts/issue3693.txtar b/internal/cmd/testdata/scripts/issue3693.txtar new file mode 100644 index 00000000000..8d78d85487d --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3693.txtar @@ -0,0 +1,6 @@ +# test that chezmoi apply does not panic when given an external with an empty configuration +! exec chezmoi apply +stderr 'missing external type' + +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml -- +[".config"] diff --git a/internal/cmd/testdata/scripts/issue3703.txtar b/internal/cmd/testdata/scripts/issue3703.txtar new file mode 100644 index 00000000000..313f283f64c --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3703.txtar @@ -0,0 +1,16 @@ +httpd www + +# test that chezmoi apply removes unmanaged files from an exact_ directory containing an external +exists $HOME/.local/bin/unmanaged +exec chezmoi apply --debug +cmp $HOME/.local/bin/file www/file +! exists $HOME/.local/bin/unmanaged + +-- home/user/.local/bin/unmanaged -- +-- home/user/.local/share/chezmoi/.chezmoiexternal.toml.tmpl -- +[".local/bin/file"] + type = "file" + url = "{{ env "HTTPD_URL" }}/file" +-- home/user/.local/share/chezmoi/dot_local/exact_bin/.keep -- +-- www/file -- +# contents of file diff --git a/internal/cmd/testdata/scripts/issue3744.txtar b/internal/cmd/testdata/scripts/issue3744.txtar new file mode 100644 index 00000000000..93e428ea272 --- /dev/null +++ b/internal/cmd/testdata/scripts/issue3744.txtar @@ -0,0 +1,22 @@ +[!exec:git] skip 'git not found in $PATH' + +mkgitconfig + +# create a repo +exec git init --bare $WORK/dotfiles.git +exec chezmoi init file://$WORK/dotfiles.git + +# test that chezmoi add creates and pushes a commit with a custom message template +stdin golden/stdin +exec chezmoi add --no-tty $HOME${/}.file +exec git --git-dir=$WORK/dotfiles.git show HEAD +stdout 'Custom commit message' + +-- golden/stdin -- +Custom commit message +-- home/user/.config/chezmoi/chezmoi.toml -- +[git] + autoPush = true + commitMessageTemplate = "{{ promptString \"message\" }}" +-- home/user/.file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/issue796.txt b/internal/cmd/testdata/scripts/issue796.txtar similarity index 78% rename from internal/cmd/testdata/scripts/issue796.txt rename to internal/cmd/testdata/scripts/issue796.txtar index cd3f52d015d..09d8839780b 100644 --- a/internal/cmd/testdata/scripts/issue796.txt +++ b/internal/cmd/testdata/scripts/issue796.txtar @@ -2,5 +2,5 @@ mkhomedir mksourcedir symlink $CHEZMOISOURCEDIR/dot_file2 -> dot_file -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.file2 $HOME/.file diff --git a/internal/cmd/testdata/scripts/keepassxc.txt b/internal/cmd/testdata/scripts/keepassxc.txt deleted file mode 100644 index a3b0eda5020..00000000000 --- a/internal/cmd/testdata/scripts/keepassxc.txt +++ /dev/null @@ -1,58 +0,0 @@ -[!windows] chmod 755 bin/keepass-test -[windows] unix2dos bin/keepass-test.cmd - -# test keepassxcAttribute template function -stdin $HOME/input -chezmoi execute-template --no-tty '{{ 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 --no-tty '{{ (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 < ../libexec/bin/mackup +symlink opt/homebrew/bin/mackup -> ../Cellar/mackup/0.8.32/bin/mackup +env PATH=$WORK/opt/homebrew/bin:$PATH + +# test that chezmoi mackup add adds normal dotfiles +exec chezmoi mackup add curl +cmp $CHEZMOISOURCEDIR/dot_curlrc golden/dot_curlrc + +# test that chezmoi mackup add adds XDG configuration files +exec chezmoi mackup add vscode +cmp $CHEZMOISOURCEDIR/dot_config/Code/User/settings.json golden/settings.json + +# test that chezmoi mackup add --secrets=error generates an error when adding a file with a secret and does not add the file +! exec chezmoi mackup add --secrets=error wget +cmpenv stderr golden/stderr +! exists $CHEZMOISOURCEDIR/dot_wgetrc + +-- golden/dot_curlrc -- +# contents of .curlrc +-- golden/settings.json -- +# contents of .config/Code/User/settings.json +-- golden/stderr -- +chezmoi: $WORK/home/user/.wgetrc:1: Uncovered a GitHub Personal Access Token, potentially leading to unauthorized repository access and sensitive content exposure. +-- home/user/.config/Code/User/settings.json -- +# contents of .config/Code/User/settings.json +-- home/user/.curlrc -- +# contents of .curlrc +-- home/user/.mackup/curl.cfg -- +[application] +name = Curl + +[configuration_files] +.netrc +.curlrc +-- home/user/.wgetrc -- +GITHUB_PERSONAL_ACCESS_TOKEN=ghp_0000000000000000000000000000000000000 +-- opt/homebrew/Cellar/mackup/0.8.32/libexec/bin/mackup -- +# mackup binary +-- opt/homebrew/Cellar/mackup/0.8.32/libexec/lib/python3.9/site-packages/mackup/applications/vscode.cfg -- +[application] +name = Visual Studio Code + +[configuration_files] +Library/Application Support/Code/User/snippets +Library/Application Support/Code/User/keybindings.json +Library/Application Support/Code/User/settings.json + +[xdg_configuration_files] +Code/User/snippets +Code/User/keybindings.json +Code/User/settings.json +-- opt/homebrew/Cellar/mackup/0.8.32/libexec/lib/python3.9/site-packages/mackup/applications/wget.cfg -- +[application] +name = Wget + +[configuration_files] +.wgetrc +.wget-hsts diff --git a/internal/cmd/testdata/scripts/mackupmacports_darwin.txtar b/internal/cmd/testdata/scripts/mackupmacports_darwin.txtar new file mode 100644 index 00000000000..a729e475cac --- /dev/null +++ b/internal/cmd/testdata/scripts/mackupmacports_darwin.txtar @@ -0,0 +1,46 @@ +[!darwin] skip 'Darwin only' + +# simulate a macports installation of mackup +chmod 755 opt/local/Library/Frameworks/Python.framework/Versions/3.11/bin/mackup +mkdir opt/local/bin +symlink opt/local/bin/mackup -> ../Library/Frameworks/Python.framework/Versions/3.11/bin/mackup +env PATH=$WORK/opt/local/bin:$PATH + +# test that chezmoi mackup add adds normal dotfiles +exec chezmoi mackup add curl +cmp $CHEZMOISOURCEDIR/dot_curlrc golden/dot_curlrc + +# test that chezmoi mackup add adds XDG configuration files +exec chezmoi mackup add vscode +cmp $CHEZMOISOURCEDIR/dot_config/Code/User/settings.json golden/settings.json + +-- golden/dot_curlrc -- +# contents of .curlrc +-- golden/settings.json -- +# contents of .config/Code/User/settings.json +-- home/user/.config/Code/User/settings.json -- +# contents of .config/Code/User/settings.json +-- home/user/.curlrc -- +# contents of .curlrc +-- home/user/.mackup/curl.cfg -- +[application] +name = Curl + +[configuration_files] +.netrc +.curlrc +-- opt/local/Library/Frameworks/Python.framework/Versions/3.11/bin/mackup -- +# mackup binary +-- opt/local/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/mackup/applications/vscode.cfg -- +[application] +name = Visual Studio Code + +[configuration_files] +Library/Application Support/Code/User/snippets +Library/Application Support/Code/User/keybindings.json +Library/Application Support/Code/User/settings.json + +[xdg_configuration_files] +Code/User/snippets +Code/User/keybindings.json +Code/User/settings.json diff --git a/internal/cmd/testdata/scripts/mackuppip_darwin.txtar b/internal/cmd/testdata/scripts/mackuppip_darwin.txtar new file mode 100644 index 00000000000..8b2b8eb64a1 --- /dev/null +++ b/internal/cmd/testdata/scripts/mackuppip_darwin.txtar @@ -0,0 +1,46 @@ +[!darwin] skip 'Darwin only' + +# simulate a pip installation of mackup +chmod 755 usr/local/bin/mackup +# version 3.9 of python without mackup +mkdir usr/local/lib/python3.9/site-packages +env PATH=$WORK/usr/local/bin:$PATH + +# test that chezmoi mackup add adds normal dotfiles +exec chezmoi mackup add curl +cmp $CHEZMOISOURCEDIR/dot_curlrc golden/dot_curlrc + +# test that chezmoi mackup add adds XDG configuration files +exec chezmoi mackup add vscode +cmp $CHEZMOISOURCEDIR/dot_config/Code/User/settings.json golden/settings.json + +-- golden/dot_curlrc -- +# contents of .curlrc +-- golden/settings.json -- +# contents of .config/Code/User/settings.json +-- home/user/.config/Code/User/settings.json -- +# contents of .config/Code/User/settings.json +-- home/user/.curlrc -- +# contents of .curlrc +-- home/user/.mackup/curl.cfg -- +[application] +name = Curl + +[configuration_files] +.netrc +.curlrc +-- usr/local/bin/mackup -- +# mackup binary +-- usr/local/lib/python3.11/site-packages/mackup/applications/vscode.cfg -- +[application] +name = Visual Studio Code + +[configuration_files] +Library/Application Support/Code/User/snippets +Library/Application Support/Code/User/keybindings.json +Library/Application Support/Code/User/settings.json + +[xdg_configuration_files] +Code/User/snippets +Code/User/keybindings.json +Code/User/settings.json diff --git a/internal/cmd/testdata/scripts/mackuppipx_darwin.txtar b/internal/cmd/testdata/scripts/mackuppipx_darwin.txtar new file mode 100644 index 00000000000..17f026944ce --- /dev/null +++ b/internal/cmd/testdata/scripts/mackuppipx_darwin.txtar @@ -0,0 +1,46 @@ +[!darwin] skip 'Darwin only' + +# simulate a pipx installation of mackup +chmod 755 home/.local/pipx/venvs/mackup/bin/mackup +mkdir home/.local/bin +symlink home/.local/bin/mackup -> ../pipx/venvs/mackup/bin/mackup +env PATH=$WORK/home/.local/bin:$PATH + +# test that chezmoi mackup add adds normal dotfiles +exec chezmoi mackup add curl +cmp $CHEZMOISOURCEDIR/dot_curlrc golden/dot_curlrc + +# test that chezmoi mackup add adds XDG configuration files +exec chezmoi mackup add vscode +cmp $CHEZMOISOURCEDIR/dot_config/Code/User/settings.json golden/settings.json + +-- golden/dot_curlrc -- +# contents of .curlrc +-- golden/settings.json -- +# contents of .config/Code/User/settings.json +-- home/.local/pipx/venvs/mackup/bin/mackup -- +# mackup binary +-- home/.local/pipx/venvs/mackup/lib/python3.11/site-packages/mackup/applications/vscode.cfg -- +[application] +name = Visual Studio Code + +[configuration_files] +Library/Application Support/Code/User/snippets +Library/Application Support/Code/User/keybindings.json +Library/Application Support/Code/User/settings.json + +[xdg_configuration_files] +Code/User/snippets +Code/User/keybindings.json +Code/User/settings.json +-- home/user/.config/Code/User/settings.json -- +# contents of .config/Code/User/settings.json +-- home/user/.curlrc -- +# contents of .curlrc +-- home/user/.mackup/curl.cfg -- +[application] +name = Curl + +[configuration_files] +.netrc +.curlrc diff --git a/internal/cmd/testdata/scripts/managed.txt b/internal/cmd/testdata/scripts/managed.txt deleted file mode 100644 index eef0556f8aa..00000000000 --- a/internal/cmd/testdata/scripts/managed.txt +++ /dev/null @@ -1,98 +0,0 @@ -mksourcedir - -# test chezmoi managed -chezmoi managed -cmp stdout golden/managed - -# test chezmoi managed --include=all -chezmoi managed --include=all -cmp stdout golden/managed-all - -# test chezmoi managed --include=dirs -chezmoi managed --include=dirs -cmp stdout golden/managed-dirs - -# test chezmoi managed --include=files -chezmoi managed --include=files -cmp stdout golden/managed-files - -# test chezmoi managed --include=symlinks -chezmoi managed --include=symlinks -cmp stdout golden/managed-symlinks - -# test chezmoi managed --exclude=files -chezmoi managed --exclude=files -cmp stdout golden/managed-except-files - -chhome home2/user - -# test that chezmoi managed does not evaluate templates -chezmoi managed --include=all -cmp stdout golden/managed2 - --- golden/managed -- -.create -.dir -.dir/file -.dir/subdir -.dir/subdir/file -.empty -.executable -.file -.private -.readonly -.remove -.symlink -.template --- golden/managed-all -- -.create -.dir -.dir/file -.dir/subdir -.dir/subdir/file -.empty -.executable -.file -.private -.readonly -.remove -.symlink -.template --- golden/managed-dirs -- -.dir -.dir/subdir --- golden/managed-files -- -.create -.dir/file -.dir/subdir/file -.empty -.executable -.file -.private -.readonly -.remove -.template --- golden/managed-symlinks -- -.symlink --- golden/managed-except-files -- -.dir -.dir/subdir -.symlink --- golden/managed2 -- -.create -.file -.symlink -.template -script --- home/user/.local/share/chezmoi/.chezmoiremove -- -.remove --- home2/user/.local/share/chezmoi/create_dot_create.tmpl -- -{{ fail "Template should not be executed }} --- home2/user/.local/share/chezmoi/dot_template.tmpl -- -{{ fail "Template should not be executed }} --- home2/user/.local/share/chezmoi/modify_dot_file.tmpl -- -{{ fail "Template should not be executed }} --- home2/user/.local/share/chezmoi/symlink_dot_symlink.tmpl -- -{{ fail "Template should not be executed }} --- home2/user/.local/share/chezmoi/run_script.tmpl -- -{{ fail "Template should not be executed }} diff --git a/internal/cmd/testdata/scripts/managed.txtar b/internal/cmd/testdata/scripts/managed.txtar new file mode 100644 index 00000000000..743c792bbb7 --- /dev/null +++ b/internal/cmd/testdata/scripts/managed.txtar @@ -0,0 +1,260 @@ +mksourcedir + +# test chezmoi managed +exec chezmoi managed +cmp stdout golden/managed + +# test chezmoi managed --exclude-encrypted +exec chezmoi managed --exclude=encrypted +cmp stdout golden/managed-exclude-encrypted + +# test chezmoi managed --exclude=files +exec chezmoi managed --exclude=files +cmp stdout golden/managed-exclude-files + +# test chezmoi managed --exclude=files,templates +exec chezmoi managed --exclude=files,templates +cmp stdout golden/managed-exclude-files-and-templates + +# test chezmoi managed --include=all +exec chezmoi managed --include=all +cmp stdout golden/managed-include-all + +# test chezmoi managed --include=dirs +exec chezmoi managed --include=dirs +cmp stdout golden/managed-include-dirs + +# test chezmoi managed --include=encrypted +exec chezmoi managed --include=encrypted +cmp stdout golden/managed-include-encrypted + +# test chezmoi managed --include=files +exec chezmoi managed --include=files +cmp stdout golden/managed-include-files + +# test chezmoi managed --include=files --exclude=templates +exec chezmoi managed --include=files --exclude=templates +cmp stdout golden/managed-include-files-exclude-templates + +# test chezmoi managed --include=symlinks +exec chezmoi managed --include=symlinks +cmp stdout golden/managed-include-symlinks + +# test chezmoi managed --include=templates +exec chezmoi managed --include=templates +cmp stdout golden/managed-include-templates + +# test chezmoi managed with arguments +exec chezmoi managed $HOME${/}.dir $HOME${/}.create +cmp stdout golden/managed-with-args + +# test chezmoi managed with child of managed dir as argument +exec chezmoi managed $HOME${/}.dir/subdir +cmp stdout golden/managed-in-managed + +# test chezmoi managed --exclude=dir with arguments +exec chezmoi managed --exclude=dirs $HOME${/}.dir $HOME${/}.create +cmp stdout golden/managed-with-nodir-args + +# test chezmoi managed with absent arguments +exec chezmoi managed $HOME${/}.dir $HOME${/}.non-exist +cmp stdout golden/managed-with-absent-args + +# test chezmoi managed --path-style=absolute +[unix] exec chezmoi managed --path-style=absolute +[unix] cmpenv stdout golden/managed-absolute + +# test chezmoi managed --path-style=source-absolute +exec chezmoi managed --path-style=source-absolute +cmpenv stdout golden/managed-source-absolute + +# test chezmoi managed --path-style=source-relative +exec chezmoi managed --path-style=source-relative +cmp stdout golden/managed-source-relative + +chhome home2/user + +# test that chezmoi managed does not evaluate templates +exec chezmoi managed --include=all +cmp stdout golden/managed2 + +-- golden/managed -- +.create +.dir +.dir/file +.dir/subdir +.dir/subdir/file +.empty +.encrypted +.executable +.file +.private +.readonly +.remove +.symlink +.template +-- golden/managed-absolute -- +$WORK/home/user/.create +$WORK/home/user/.dir +$WORK/home/user/.dir/file +$WORK/home/user/.dir/subdir +$WORK/home/user/.dir/subdir/file +$WORK/home/user/.empty +$WORK/home/user/.encrypted +$WORK/home/user/.executable +$WORK/home/user/.file +$WORK/home/user/.private +$WORK/home/user/.readonly +$WORK/home/user/.remove +$WORK/home/user/.symlink +$WORK/home/user/.template +-- golden/managed-exclude-encrypted -- +.create +.dir +.dir/file +.dir/subdir +.dir/subdir/file +.empty +.executable +.file +.private +.readonly +.remove +.symlink +.template +-- golden/managed-exclude-files -- +.dir +.dir/subdir +.symlink +-- golden/managed-exclude-files-and-templates -- +.dir +.dir/subdir +.symlink +-- golden/managed-exclude-templates -- +.create +.dir +.dir/file +.dir/subdir +.dir/subdir/file +.empty +.encrypted +.executable +.file +.private +.readonly +.remove +.symlink +-- golden/managed-in-managed -- +.dir/subdir +.dir/subdir/file +-- golden/managed-include-all -- +.create +.dir +.dir/file +.dir/subdir +.dir/subdir/file +.empty +.encrypted +.executable +.file +.private +.readonly +.remove +.symlink +.template +-- golden/managed-include-dirs -- +.dir +.dir/subdir +-- golden/managed-include-encrypted -- +.encrypted +-- golden/managed-include-files -- +.create +.dir/file +.dir/subdir/file +.empty +.encrypted +.executable +.file +.private +.readonly +.remove +.template +-- golden/managed-include-files-exclude-templates -- +.create +.dir/file +.dir/subdir/file +.empty +.encrypted +.executable +.file +.private +.readonly +.remove +-- golden/managed-include-symlinks -- +.symlink +-- golden/managed-include-templates -- +.template +-- golden/managed-source-absolute -- +${CHEZMOISOURCEDIR}/create_dot_create +${CHEZMOISOURCEDIR}/dot_dir +${CHEZMOISOURCEDIR}/dot_dir/exact_subdir +${CHEZMOISOURCEDIR}/dot_dir/exact_subdir/file +${CHEZMOISOURCEDIR}/dot_dir/file +${CHEZMOISOURCEDIR}/dot_file +${CHEZMOISOURCEDIR}/dot_remove +${CHEZMOISOURCEDIR}/dot_template.tmpl +${CHEZMOISOURCEDIR}/empty_dot_empty +${CHEZMOISOURCEDIR}/encrypted_dot_encrypted +${CHEZMOISOURCEDIR}/executable_dot_executable +${CHEZMOISOURCEDIR}/private_dot_private +${CHEZMOISOURCEDIR}/readonly_dot_readonly +${CHEZMOISOURCEDIR}/symlink_dot_symlink +-- golden/managed-source-relative -- +create_dot_create +dot_dir +dot_dir/exact_subdir +dot_dir/exact_subdir/file +dot_dir/file +dot_file +dot_remove +dot_template.tmpl +empty_dot_empty +encrypted_dot_encrypted +executable_dot_executable +private_dot_private +readonly_dot_readonly +symlink_dot_symlink +-- golden/managed-with-absent-args -- +.dir +.dir/file +.dir/subdir +.dir/subdir/file +-- golden/managed-with-args -- +.create +.dir +.dir/file +.dir/subdir +.dir/subdir/file +-- golden/managed-with-nodir-args -- +.create +.dir/file +.dir/subdir/file +-- golden/managed2 -- +.create +.file +.symlink +.template +script +-- home/user/.local/share/chezmoi/.chezmoiremove -- +.remove +-- home/user/.local/share/chezmoi/encrypted_dot_encrypted -- +-- home2/user/.local/share/chezmoi/create_dot_create.tmpl -- +{{ fail "Template should not be executed" }} +-- home2/user/.local/share/chezmoi/dot_template.tmpl -- +{{ fail "Template should not be executed" }} +-- home2/user/.local/share/chezmoi/modify_dot_file.tmpl -- +{{ fail "Template should not be executed" }} +-- home2/user/.local/share/chezmoi/run_script.tmpl -- +{{ fail "Template should not be executed" }} +-- home2/user/.local/share/chezmoi/symlink_dot_symlink.tmpl -- +{{ fail "Template should not be executed" }} diff --git a/internal/cmd/testdata/scripts/managedtree.txtar b/internal/cmd/testdata/scripts/managedtree.txtar new file mode 100644 index 00000000000..5d5f8c47063 --- /dev/null +++ b/internal/cmd/testdata/scripts/managedtree.txtar @@ -0,0 +1,20 @@ +mksourcedir + +# test that chezmoi managed --tree produces tree-like output +exec chezmoi managed --tree +cmp stdout golden/stdout + +-- golden/stdout -- +.create +.dir + file + subdir + file +.empty +.executable +.file +.private +.readonly +.remove +.symlink +.template diff --git a/internal/cmd/testdata/scripts/merge_unix.txt b/internal/cmd/testdata/scripts/merge_unix.txtar similarity index 73% rename from internal/cmd/testdata/scripts/merge_unix.txt rename to internal/cmd/testdata/scripts/merge_unix.txtar index a0d3bb3a465..37e4036a6f9 100644 --- a/internal/cmd/testdata/scripts/merge_unix.txt +++ b/internal/cmd/testdata/scripts/merge_unix.txtar @@ -4,7 +4,7 @@ mkhomedir mksourcedir # test that chezmoi merge does a three-way merge -chezmoi merge $HOME${/}.file +exec chezmoi merge $HOME${/}.file stdout ^${HOME@R}/\.file\s+${CHEZMOISOURCEDIR@R}/dot_file\s+${WORK@R}/.*/\.file$ # test that chezmoi merge falls back to a two-way merge when the template is invalid @@ -15,15 +15,21 @@ stdout ^${HOME@R}/\.file\s+${CHEZMOISOURCEDIR@R}/dot_file\s+${WORK@R}/.*/\.file$ chhome home2/user # test that chezmoi merge does a three-way merge with the arguments in the configured order -chezmoi merge $HOME${/}.file +exec chezmoi merge $HOME${/}.file stdout ^${CHEZMOISOURCEDIR@R}/dot_file\s+${HOME@R}/\.file\s+${WORK@R}/.*/\.file$ chhome home3/user # test that chezmoi merge appends the destination, source, and target paths if merge.args does not contain any templates -chezmoi merge $HOME${/}.file +exec chezmoi merge $HOME${/}.file stdout ^arg\s+${HOME@R}/\.file\s+${CHEZMOISOURCEDIR@R}/dot_file\s+${WORK@R}/.*/\.file$ +chhome home4/user + +# test that chezmoi merge respects .chezmoiroot +exec chezmoi merge $HOME${/}.file +stdout ^${HOME@R}/\.file\s+${CHEZMOISOURCEDIR@R}/home/dot_file\s+${WORK@R}/.*/\.file$ + -- home/user/.config/chezmoi/chezmoi.toml -- [merge] command = "echo" @@ -44,3 +50,12 @@ stdout ^arg\s+${HOME@R}/\.file\s+${CHEZMOISOURCEDIR@R}/dot_file\s+${WORK@R}/.*/\ args = ["arg"] -- home3/user/.local/share/chezmoi/dot_file -- # source +-- home4/user/.config/chezmoi/chezmoi.yaml -- +merge: + command: "echo" +-- home4/user/.file -- +# destination +-- home4/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home4/user/.local/share/chezmoi/home/dot_file -- +# source diff --git a/internal/cmd/testdata/scripts/mergeall_unix.txt b/internal/cmd/testdata/scripts/mergeall_unix.txtar similarity index 68% rename from internal/cmd/testdata/scripts/mergeall_unix.txt rename to internal/cmd/testdata/scripts/mergeall_unix.txtar index d3e54210de5..5e1819ae7ff 100644 --- a/internal/cmd/testdata/scripts/mergeall_unix.txt +++ b/internal/cmd/testdata/scripts/mergeall_unix.txtar @@ -1,18 +1,29 @@ [windows] skip 'UNIX only' # test that chezmoi merge-all does not run the merge command if nothing is modified -chezmoi merge-all +exec chezmoi merge-all ! stdout . # test that chezmoi merge-all runs the merge command if a file is modified edit $HOME/.file -chezmoi merge-all +exec chezmoi merge-all stdout ^${HOME@R}/\.file\s+${CHEZMOISOURCEDIR@R}/dot_file\.tmpl\s+${WORK@R}/.*/\.file$ --- home/user/.file -- -# contents of .file +chhome home2/user + +# test that chezmoi merge-all only merges files +exec chezmoi merge-all +! stdout . +! stderr . + -- home/user/.config/chezmoi/chezmoi.toml -- [merge] command = "echo" +-- home/user/.file -- +# contents of .file -- home/user/.local/share/chezmoi/dot_file.tmpl -- {{ "# contents of .file" }} +-- home2/user/.local/share/chezmoi/run_script.sh -- +#!/bin/sh + +echo script diff --git a/internal/cmd/testdata/scripts/mergeencryptedage_unix.txtar b/internal/cmd/testdata/scripts/mergeencryptedage_unix.txtar new file mode 100644 index 00000000000..35d42b8b776 --- /dev/null +++ b/internal/cmd/testdata/scripts/mergeencryptedage_unix.txtar @@ -0,0 +1,21 @@ +[windows] skip 'UNIX only' +[!exec:age] skip 'age not found in $PATH' + +mkageconfig +appendline $CHEZMOICONFIGDIR/chezmoi.toml '[merge]' +appendline $CHEZMOICONFIGDIR/chezmoi.toml ' command = "cat"' + +# test that chezmoi merge works on files encrypted with age +exec chezmoi add --encrypt $HOME${/}.dir/file +exists $CHEZMOISOURCEDIR/dot_dir/encrypted_file.age +edit $HOME${/}.dir/file +exec chezmoi merge $HOME${/}.dir/file +cmp stdout golden/merge + +-- golden/merge -- +# contents of .dir/file +# edited +# contents of .dir/file +# contents of .dir/file +-- home/user/.dir/file -- +# contents of .dir/file diff --git a/internal/cmd/testdata/scripts/mergeencrypted_unix.txt b/internal/cmd/testdata/scripts/mergeencryptedgpg_unix.txtar similarity index 77% rename from internal/cmd/testdata/scripts/mergeencrypted_unix.txt rename to internal/cmd/testdata/scripts/mergeencryptedgpg_unix.txtar index a598bf8c239..33f2f6e9164 100644 --- a/internal/cmd/testdata/scripts/mergeencrypted_unix.txt +++ b/internal/cmd/testdata/scripts/mergeencryptedgpg_unix.txtar @@ -8,10 +8,10 @@ appendline $CHEZMOICONFIGDIR/chezmoi.toml ' command = "cat"' # test that chezmoi merge transparently decrypts the source cp golden/source $HOME/.file -chezmoi add --encrypt $HOME${/}.file -chezmoi chattr +template $HOME${/}.file +exec chezmoi add --encrypt $HOME${/}.file +exec chezmoi chattr +template $HOME${/}.file cp golden/destination $HOME/.file -chezmoi merge $HOME${/}.file +exec chezmoi merge $HOME${/}.file cmp stdout golden/expected chhome home2/user @@ -22,11 +22,11 @@ appendline $CHEZMOICONFIGDIR/chezmoi.toml ' command = "edit-source"' # test that chezmoi merge transparently re-encrypts the source if it is edited cp golden/source $HOME/.file -chezmoi add --encrypt $HOME${/}.file -chezmoi chattr +template $HOME${/}.file +exec chezmoi add --encrypt $HOME${/}.file +exec chezmoi chattr +template $HOME${/}.file cp golden/destination $HOME/.file -chezmoi merge $HOME${/}.file -chezmoi cat $HOME${/}.file +exec chezmoi merge $HOME${/}.file +exec chezmoi cat $HOME${/}.file cmp stdout golden/edited-target -- bin/edit-source -- @@ -35,12 +35,12 @@ cmp stdout golden/edited-target echo "# edited" >> $2 -- golden/destination -- destination +-- golden/edited-target -- +target +# edited -- golden/expected -- destination {{ "target" }} target -- golden/source -- {{ "target" }} --- golden/edited-target -- -target -# edited diff --git a/internal/cmd/testdata/scripts/modesymlink.txt b/internal/cmd/testdata/scripts/modesymlink.txtar similarity index 95% rename from internal/cmd/testdata/scripts/modesymlink.txt rename to internal/cmd/testdata/scripts/modesymlink.txtar index 08807e88cdb..e36c99da66c 100644 --- a/internal/cmd/testdata/scripts/modesymlink.txt +++ b/internal/cmd/testdata/scripts/modesymlink.txtar @@ -2,7 +2,7 @@ mkhomedir golden mksourcedir # test that chezmoi apply does not create symlinks by default -chezmoi apply +exec chezmoi apply cmp $HOME/.create golden/.create ! issymlink $HOME/.create cmp $HOME/.dir/file golden/.dir/file @@ -22,7 +22,7 @@ cmp $HOME/.template golden/.template ! issymlink $HOME/.template # test that chezmoi apply --mode=symlink creates symlinks where possible -chezmoi apply --mode=symlink +exec chezmoi apply --mode=symlink cmp $HOME/.create golden/.create ! issymlink $HOME/.create cmp $HOME/.dir/file golden/.dir/file diff --git a/internal/cmd/testdata/scripts/modify_unix.txt b/internal/cmd/testdata/scripts/modify_unix.txtar similarity index 65% rename from internal/cmd/testdata/scripts/modify_unix.txt rename to internal/cmd/testdata/scripts/modify_unix.txtar index a998e36ce1c..fc6cc752fad 100644 --- a/internal/cmd/testdata/scripts/modify_unix.txt +++ b/internal/cmd/testdata/scripts/modify_unix.txtar @@ -5,57 +5,69 @@ cp golden/.modify home/user # test that chezmoi cat prints the modified contents without modifying the file -chezmoi cat $HOME${/}.modify +exec chezmoi cat $HOME${/}.modify cmp stdout golden/.modified cmp home/user/.modify golden/.modify # test that chezmoi diff prints the diff without modifying the file -chezmoi diff -cmp stdout golden/diff +exec chezmoi diff +cmp stdout golden/diff.diff cmp home/user/.modify golden/.modify # test that chezmoi archive includes the modified file -chezmoi archive --output=archive.tar +exec chezmoi archive --output=archive.tar exec tar xf archive.tar cmp .modify golden/.modified cmp home/user/.modify golden/.modify # test that chezmoi apply modifies the file -chezmoi apply --force +exec chezmoi apply --force cmp home/user/.modify golden/.modified chhome home2/user # test that chezmoi cat does not fail or generate output when the target does not exist -chezmoi cat $HOME${/}.not_exist +exec chezmoi cat $HOME${/}.not_exist ! stdout . # test that chezmoi cat exits with an error when the modify script fails -! chezmoi cat $HOME${/}.error +! exec chezmoi cat $HOME${/}.error stderr error # test that chezmoi apply updates file permissions cmpmod 666 $HOME/.file -chezmoi apply $HOME${/}.file +exec chezmoi apply $HOME${/}.file cmpmod 700 $HOME/.file chhome home3/user # test that chezmoi apply always overwrites modified files without --force -chezmoi add $HOME${/}.modify -chezmoi apply +exec chezmoi add $HOME${/}.modify +exec chezmoi apply edit $HOME${/}.modify rm $CHEZMOISOURCEDIR/dot_modify cp home/user/.local/share/chezmoi/modify_dot_modify $CHEZMOISOURCEDIR -chezmoi apply +exec chezmoi apply cmp $HOME${/}.modify golden/.edited-and-modified chhome home4/user # test that modify scripts can be templates -chezmoi cat $HOME${/}.modify +exec chezmoi cat $HOME${/}.modify cmp stdout golden/.modified +chhome home5/user + +# test that modify scripts can be modify-templates +exec chezmoi cat $HOME${/}.modify +cmp stdout golden/.modified + +chhome home6/user + +# test that modify scripts can use modify-templates to modify JSON fields +exec chezmoi apply --force +cmp $HOME/.modify.json golden/.modified.json + -- golden/.edited-and-modified -- beginning modified-middle @@ -65,11 +77,13 @@ end beginning modified-middle end +-- golden/.modified.json -- +{"key1":{"key2":"value","key3":"value3"}} -- golden/.modify -- beginning middle end --- golden/diff -- +-- golden/diff.diff -- diff --git a/.modify b/.modify index f91830d4ecd80adfe9a6aea9dca579397aa86921..6b6d41aae5e8d64a54afd8b8ad5a38a3de1e1e35 100644 --- a/.modify @@ -85,15 +99,15 @@ index f91830d4ecd80adfe9a6aea9dca579397aa86921..6b6d41aae5e8d64a54afd8b8ad5a38a3 sed 's/middle/modified-middle/g' -- home2/user/.file -- # contents of .file --- home2/user/.local/share/chezmoi/modify_dot_not_exist -- -#!/bin/sh - -cat -- home2/user/.local/share/chezmoi/modify_dot_error -- #!/bin/sh echo error >2 exit 1 +-- home2/user/.local/share/chezmoi/modify_dot_not_exist -- +#!/bin/sh + +cat -- home2/user/.local/share/chezmoi/modify_private_executable_dot_file -- #!/bin/sh @@ -102,11 +116,23 @@ cat beginning middle end --- home4/user/.modify -- -beginning -middle -end -- home4/user/.local/share/chezmoi/modify_dot_modify.tmpl -- #!/bin/sh {{ "sed 's/middle/modified-middle/g'" }} +-- home4/user/.modify -- +beginning +middle +end +-- home5/user/.local/share/chezmoi/modify_dot_modify -- +{{- /* chezmoi:modify-template */ -}} +{{- .chezmoi.stdin | replaceAllRegex "middle" "modified-middle" -}} +-- home5/user/.modify -- +beginning +middle +end +-- home6/user/.local/share/chezmoi/modify_dot_modify.json -- +{{- /* chezmoi:modify-template */ -}} +{{ fromJson .chezmoi.stdin | setValueAtPath "key1.key2" "value" | toJson }} +-- home6/user/.modify.json -- +{"key1":{"key2":"value2","key3":"value3"}} diff --git a/internal/cmd/testdata/scripts/modify_windows.txt b/internal/cmd/testdata/scripts/modify_windows.txtar similarity index 90% rename from internal/cmd/testdata/scripts/modify_windows.txt rename to internal/cmd/testdata/scripts/modify_windows.txtar index aabb8b6d0a3..b704319b7a6 100644 --- a/internal/cmd/testdata/scripts/modify_windows.txt +++ b/internal/cmd/testdata/scripts/modify_windows.txtar @@ -1,14 +1,14 @@ -[!windows] skip 'Windows only' +[unix] skip 'Windows only' # test that chezmoi apply modifies a file with a Batch script -chezmoi apply +exec chezmoi apply unix2dos golden/modified cmp $HOME/.file golden/modified chhome home2/user # test that chezmoi apply returns an error when there are multiple modify scripts for the same target -! chezmoi apply +! exec chezmoi apply stderr 'inconsistent state' -- golden/modified -- diff --git a/internal/cmd/testdata/scripts/modifyencrypted.txt b/internal/cmd/testdata/scripts/modifyencrypted.txtar similarity index 76% rename from internal/cmd/testdata/scripts/modifyencrypted.txt rename to internal/cmd/testdata/scripts/modifyencrypted.txtar index b33174ab8a4..3202628851f 100644 --- a/internal/cmd/testdata/scripts/modifyencrypted.txt +++ b/internal/cmd/testdata/scripts/modifyencrypted.txtar @@ -5,9 +5,9 @@ mkageconfig cp golden/.modify $HOME mkdir $CHEZMOISOURCEDIR -chezmoi encrypt --output=$CHEZMOISOURCEDIR${/}modify_encrypted_dot_modify.age golden/modify.sh +exec chezmoi encrypt --output=$CHEZMOISOURCEDIR${/}modify_encrypted_dot_modify.age golden/modify.sh grep '-----BEGIN AGE ENCRYPTED FILE-----' $CHEZMOISOURCEDIR/modify_encrypted_dot_modify.age -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.modify golden/.modify-modified chhome home2/user @@ -16,9 +16,9 @@ chhome home2/user mkageconfig cp golden/.modify $HOME mkdir $CHEZMOISOURCEDIR -chezmoi encrypt --output=$CHEZMOISOURCEDIR${/}modify_encrypted_dot_modify.tmpl.age golden/modify.sh.tmpl +exec chezmoi encrypt --output=$CHEZMOISOURCEDIR${/}modify_encrypted_dot_modify.tmpl.age golden/modify.sh.tmpl grep '-----BEGIN AGE ENCRYPTED FILE-----' $CHEZMOISOURCEDIR/modify_encrypted_dot_modify.tmpl.age -chezmoi apply --force +exec chezmoi apply --force cmp $HOME/.modify golden/.modify-modified -- golden/.modify -- diff --git a/internal/cmd/testdata/scripts/modifypython_windows.txt b/internal/cmd/testdata/scripts/modifypython_windows.txtar similarity index 65% rename from internal/cmd/testdata/scripts/modifypython_windows.txt rename to internal/cmd/testdata/scripts/modifypython_windows.txtar index ddaaefd19a6..4b5df8c0945 100644 --- a/internal/cmd/testdata/scripts/modifypython_windows.txt +++ b/internal/cmd/testdata/scripts/modifypython_windows.txtar @@ -1,8 +1,8 @@ -[!windows] skip 'Windows only' -[!exec:python] skip 'python not found in %PATH%' +[unix] skip 'Windows only' +[!exec:python3] skip 'python3 not found in %PATH%' -# test that chezmoi apply modifies a file with python -chezmoi apply +# test that chezmoi apply modifies a file with python3 +exec chezmoi apply unix2dos golden/modified cmp $HOME/.file golden/modified diff --git a/internal/cmd/testdata/scripts/noencryption.txt b/internal/cmd/testdata/scripts/noencryption.txtar similarity index 69% rename from internal/cmd/testdata/scripts/noencryption.txt rename to internal/cmd/testdata/scripts/noencryption.txtar index 65530162aae..03cc6d5ed3a 100644 --- a/internal/cmd/testdata/scripts/noencryption.txt +++ b/internal/cmd/testdata/scripts/noencryption.txtar @@ -1,12 +1,12 @@ mkhomedir # test that chezmoi add --encrypt without encryption fails -! chezmoi add --encrypt $HOME${/}.encrypted +! exec chezmoi add --encrypt $HOME${/}.encrypted stderr 'no encryption' # test that chezmoi apply without encryption fails -! chezmoi apply --force -stderr 'no encryption' +! exec chezmoi apply --force +stderr \.encrypted:\sno\sencryption$ -- home/user/.encrypted -- # contents of .encrypted diff --git a/internal/cmd/testdata/scripts/nosourcedir.txtar b/internal/cmd/testdata/scripts/nosourcedir.txtar new file mode 100644 index 00000000000..941f7fb7983 --- /dev/null +++ b/internal/cmd/testdata/scripts/nosourcedir.txtar @@ -0,0 +1,4 @@ +# test that chezmoi apply returns an error if the source directory does not exist +! exec chezmoi apply +[unix] stderr 'no such file or directory' +[windows] stderr 'The system cannot find the path specified' diff --git a/internal/cmd/testdata/scripts/onepassword.txt b/internal/cmd/testdata/scripts/onepassword.txt deleted file mode 100644 index 20f31bc795c..00000000000 --- a/internal/cmd/testdata/scripts/onepassword.txt +++ /dev/null @@ -1,39 +0,0 @@ -[!windows] chmod 755 bin/op -[windows] unix2dos bin/op.cmd - -# test onepassword template function -chezmoi execute-template '{{ (onepassword "ExampleLogin").uuid }}' -stdout '^wxcplh5udshnonkzg2n4qx262y$' - -# test onepasswordDetailsFields template function -chezmoi execute-template '{{ (onepasswordDetailsFields "ExampleLogin").password.value }}' -stdout '^L8rm1JXJIE1b8YUDWq7h$' - -# test onepasswordItemFields template function -chezmoi execute-template '{{ (onepasswordItemFields "ExampleLogin").exampleLabel.v }}' -stdout exampleValue - --- bin/op -- -#!/bin/sh - -case "$*" in -"--version") - echo 1.3.0 - ;; -"get item ExampleLogin") - echo '{"uuid":"wxcplh5udshnonkzg2n4qx262y","templateUuid":"001","trashed":"N","createdAt":"2020-07-28T13:44:57Z","updatedAt":"2020-07-28T14:27:46Z","changerUuid":"VBDXOA4MPVHONK5IIJVKUQGLXM","itemVersion":2,"vaultUuid":"tscpxgi6s7c662jtqn3vmw4n5a","details":{"fields":[{"designation":"username","name":"username","type":"T","value":"exampleuser"},{"designation":"password","name":"password","type":"P","value":"L8rm1JXJIE1b8YUDWq7h"}],"notesPlain":"","passwordHistory":[],"sections":[{"name":"linked items","title":"Related Items"},{"fields":[{"k":"string","n":"D4328E0846D2461E8E455D7A07B93397","t":"exampleLabel","v":"exampleValue"}],"name":"Section_20E0BD380789477D8904F830BFE8A121","title":""}]},"overview":{"URLs":[{"l":"website","u":"https://www.example.com/"}],"ainfo":"exampleuser","pbe":119.083926,"pgrng":true,"ps":100,"tags":[],"title":"ExampleLogin","url":"https://www.example.com/"}}' - ;; -*) - echo [ERROR] 2020/01/01 00:00:00 unknown command \"$*\" for \"op\" - exit 1 -esac --- bin/op.cmd -- -@echo off -IF "%*" == "--version" ( - echo 1.3.0 -) ELSE IF "%*" == "get item ExampleLogin" ( - echo.{"uuid":"wxcplh5udshnonkzg2n4qx262y","templateUuid":"001","trashed":"N","createdAt":"2020-07-28T13:44:57Z","updatedAt":"2020-07-28T14:27:46Z","changerUuid":"VBDXOA4MPVHONK5IIJVKUQGLXM","itemVersion":2,"vaultUuid":"tscpxgi6s7c662jtqn3vmw4n5a","details":{"fields":[{"designation":"username","name":"username","type":"T","value":"exampleuser"},{"designation":"password","name":"password","type":"P","value":"L8rm1JXJIE1b8YUDWq7h"}],"notesPlain":"","passwordHistory":[],"sections":[{"name":"linked items","title":"Related Items"},{"fields":[{"k":"string","n":"D4328E0846D2461E8E455D7A07B93397","t":"exampleLabel","v":"exampleValue"}],"name":"Section_20E0BD380789477D8904F830BFE8A121","title":""}]},"overview":{"URLs":[{"l":"website","u":"https://www.example.com/"}],"ainfo":"exampleuser","pbe":119.083926,"pgrng":true,"ps":100,"tags":[],"title":"ExampleLogin","url":"https://www.example.com/"}} -) ELSE ( - echo.[ERROR] 2020/01/01 00:00:00 unknown command "%*" for "op" - exit /b 1 -) diff --git a/internal/cmd/testdata/scripts/onepassword2.txtar b/internal/cmd/testdata/scripts/onepassword2.txtar new file mode 100644 index 00000000000..c1f9316b649 --- /dev/null +++ b/internal/cmd/testdata/scripts/onepassword2.txtar @@ -0,0 +1,172 @@ +[unix] chmod 755 bin/op +[windows] unix2dos bin/op.cmd + +# test onepassword template function +exec chezmoi execute-template '{{ (onepassword "ExampleLogin").id }}' +stdout '^wxcplh5udshnonkzg2n4qx262y$' + +# test onepassword template function with vault and account +exec chezmoi execute-template '{{ (onepassword "ExampleLogin" "vault" "account").id }}' +stdout '^wxcplh5udshnonkzg2n4qx262y$' + +# test onepassword template function with empty vault +exec chezmoi execute-template '{{ (onepassword "ExampleLogin" "" "account").id }}' +stdout '^wxcplh5udshnonkzg2n4qx262y$' + +# test onepassword template function with account alias +exec chezmoi execute-template '{{ (onepassword "ExampleLogin" "" "chezmoi").id }}' +stdout '^wxcplh5udshnonkzg2n4qx262y$' + +# test onepasswordDetailsFields template function +exec chezmoi execute-template '{{ (onepasswordDetailsFields "ExampleLogin").password.value }}' +stdout '^L8rm1JXJIE1b8YUDWq7h$' + +# test onepasswordItemFields template function +exec chezmoi execute-template '{{ (onepasswordItemFields "ExampleLogin").exampleLabel.value }}' +stdout exampleValue + +# test onepasswordRead template function +exec chezmoi execute-template '{{ onepasswordRead "op://vault/item/field" }}' +stdout exampleField + +# test onepasswordRead template function with account +exec chezmoi execute-template '{{ onepasswordRead "op://vault/item/field" "account" }}' +stdout exampleAccountField + +# test onepasswordDocument template function +exec chezmoi execute-template '{{ onepasswordDocument "exampleDocument" }}' +stdout 'OK-COMPUTER' + +# test onepasswordDocument template function with vault +exec chezmoi execute-template '{{ onepasswordDocument "exampleDocument" "vault" }}' +stdout 'OK-VAULT' + +# test onepasswordDocument template function with vault and account +exec chezmoi execute-template '{{ onepasswordDocument "exampleDocument" "vault" "account" }}' +stdout 'OK-VAULT-ACCOUNT' + +# test onepasswordDocument template function with account +exec chezmoi execute-template '{{ onepasswordDocument "exampleDocument" "" "account" }}' +stdout 'OK-ACCOUNT' + +# test onepassword template function (insufficient parameters) +! exec chezmoi execute-template '{{ (onepassword).id }}' +stderr 'expected 1..3 arguments in account mode, got 0' + +# test onepassword template function (too many parameters) +! exec chezmoi execute-template '{{ (onepassword "ExampleLogin" "vault" "account" "extra").id }}' +stderr 'expected 1..3 arguments in account mode, got 4' + +# test onepasswordRead template function (too many parameters) +! exec chezmoi execute-template '{{ onepasswordRead "op://vault/item/field" "account" "extra" }}' +stderr 'expected 1..2 arguments, got 3' + +# test failure with OP_SERVICE_ACCOUNT_TOKEN set +env OP_SERVICE_ACCOUNT_TOKEN=x +! exec chezmoi execute-template '{{ (onepassword "ExampleLogin").id }}' +stderr 'OP_SERVICE_ACCOUNT_TOKEN is set' + +# test failure with OP_CONNECT_HOST and OP_CONNECT_TOKEN set +env OP_SERVICE_ACCOUNT_TOKEN= +env OP_CONNECT_HOST=x +env OP_CONNECT_TOKEN=y +! exec chezmoi execute-template '{{ (onepassword "ExampleLogin").id }}' +stderr 'OP_CONNECT_HOST and OP_CONNECT_TOKEN' + +-- bin/op -- +#!/bin/sh + +if [ "$*" = "--version" ]; then + echo 2.0.0 +elif [ "$*" = "item get --format json ExampleLogin --vault vault --account account_uuid" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "item get --format json ExampleLogin --account account_uuid" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault --account account_uuid" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin --account account_uuid" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "item get --format json ExampleLogin" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "account list --format=json" ]; then + echo '[{"url":"account.1password.com","email":"chezmoi@chezmoi.org","user_uuid":"user_uuid","account_uuid":"account_uuid"}]' +elif [ "$*" = "signin --account account_uuid --raw" ]; then + echo 'thisIsAFakeSessionToken' +elif [ "$*" = "signin --raw" ]; then + echo 'thisIsAFakeSessionToken' +elif [ "$*" = "read --no-newline op://vault/item/field" ]; then + echo 'exampleField' +elif [ "$*" = "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field" ]; then + echo 'exampleField' +elif [ "$*" = "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field --account account_uuid" ]; then + echo 'exampleAccountField' +elif [ "$*" = "document get exampleDocument" ]; then + echo 'OK-COMPUTER' +elif [ "$*" = "document get exampleDocument --vault vault" ]; then + echo 'OK-VAULT' +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument" ]; then + echo 'OK-COMPUTER' +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument --vault vault" ]; then + echo 'OK-VAULT' +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument --account account_uuid" ]; then + echo 'OK-ACCOUNT' +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument --vault vault --account account_uuid" ]; then + echo 'OK-VAULT-ACCOUNT' +else + echo [ERROR] 2020/01/01 00:00:00 unknown command \"$*\" for \"op\" 1>&2 + exit 1 +fi +-- bin/op.cmd -- +@echo off +IF "%*" == "--version" ( + echo 2.0.0 +) ELSE IF "%*" == "item get --format json ExampleLogin --vault vault --account account_uuid" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "item get --format json ExampleLogin --account account_uuid" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault --account account_uuid" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin --account account_uuid" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "item get --format json ExampleLogin" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "item get --format json ExampleLogin --vault vault" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "account list --format=json" ( + echo.[{"url":"account.1password.com","email":"chezmoi@chezmoi.org","user_uuid":"user_uuid","account_uuid":"account_uuid"}] +) ELSE IF "%*" == "signin --account account_uuid --raw" ( + echo thisIsAFakeSessionToken +) ELSE IF "%*" == "signin --raw" ( + echo thisIsAFakeSessionToken +) ELSE IF "%*" == "document get exampleDocument" ( + echo.OK-COMPUTER +) ELSE IF "%*" == "document get exampleDocument --vault vault" ( + echo.OK-VAULT +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument" ( + echo.OK-COMPUTER +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument --vault vault" ( + echo.OK-VAULT +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument --account account_uuid" ( + echo.OK-ACCOUNT +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument --vault vault --account account_uuid" ( + echo.OK-VAULT-ACCOUNT +) ELSE IF "%*" == "read --no-newline op://vault/item/field" ( + echo.exampleField +) ELSE IF "%*" == "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field" ( + echo.exampleField +) ELSE IF "%*" == "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field --account account_uuid" ( + echo.exampleAccountField +) ELSE ( + echo.[ERROR] 2020/01/01 00:00:00 unknown command "%*" for "op" 1>&2 + exit /b 1 +) diff --git a/internal/cmd/testdata/scripts/onepassword2connect.txtar b/internal/cmd/testdata/scripts/onepassword2connect.txtar new file mode 100644 index 00000000000..b79e1388a37 --- /dev/null +++ b/internal/cmd/testdata/scripts/onepassword2connect.txtar @@ -0,0 +1,189 @@ +[unix] chmod 755 bin/op +[windows] unix2dos bin/op.cmd + +mkhomedir + +# test that mode is properly set and reported +exec chezmoi execute-template '{{ .chezmoi.config.onepassword.mode }}' +stdout '^connect$' + +# test failure without OP_CONNECT_HOST set +! exec chezmoi execute-template '{{ (onepassword "ExampleLogin").id }}' +stderr 'OP_CONNECT_HOST' + +env OP_CONNECT_HOST=x + +# test failure without OP_CONNECT_TOKEN set +! exec chezmoi execute-template '{{ (onepassword "ExampleLogin").id }}' +stderr 'OP_CONNECT_TOKEN' + +env OP_CONNECT_TOKEN=y + +# test onepassword template function +exec chezmoi execute-template '{{ (onepassword "ExampleLogin").id }}' +stdout '^wxcplh5udshnonkzg2n4qx262y$' + +# test onepassword template function with vault +exec chezmoi execute-template '{{ (onepassword "ExampleLogin" "vault").id }}' +stdout '^wxcplh5udshnonkzg2n4qx262y$' + +# test failure onepassword template function with vault and account +! exec chezmoi execute-template '{{ (onepassword "ExampleLogin" "vault" "account").id }}' +stderr '1Password account parameters cannot be used in connect mode' + +# test onepassword template function with empty vault +exec chezmoi execute-template '{{ (onepassword "ExampleLogin" "").id }}' +stdout '^wxcplh5udshnonkzg2n4qx262y$' + +# test onepasswordDetailsFields template function +exec chezmoi execute-template '{{ (onepasswordDetailsFields "ExampleLogin").password.value }}' +stdout '^L8rm1JXJIE1b8YUDWq7h$' + +# test onepasswordItemFields template function +exec chezmoi execute-template '{{ (onepasswordItemFields "ExampleLogin").exampleLabel.value }}' +stdout exampleValue + +# test onepasswordRead template function +exec chezmoi execute-template '{{ onepasswordRead "op://vault/item/field" }}' +stdout exampleField + +# test failure onepasswordRead template function with account +! exec chezmoi execute-template '{{ onepasswordRead "op://vault/item/field" "account" }}' +stderr '1Password account parameters cannot be used in connect mode' + +# test failure onepasswordDocument template function +! exec chezmoi execute-template '{{ onepasswordDocument "exampleDocument" }}' +stderr 'onepasswordDocument cannot be used in connect mode' + +# test failure with OP_SERVICE_ACCOUNT_TOKEN set +env OP_SERVICE_ACCOUNT_TOKEN=x +! exec chezmoi execute-template '{{ (onepassword "ExampleLogin").id }}' +stderr 'OP_SERVICE_ACCOUNT_TOKEN is set' + +-- bin/op -- +#!/bin/sh + +if [ "$*" = "--version" ]; then + echo 2.0.0 +elif [ "$*" = "item get --format json ExampleLogin --vault vault --account account_uuid" ]; then + echo "[ERROR] cannot use accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "item get --format json ExampleLogin --account account_uuid" ]; then + echo "[ERROR] cannot use accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault --account account_uuid" ]; then + echo "[ERROR] cannot use accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin --account account_uuid" ]; then + echo "[ERROR] cannot use accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "item get --format json ExampleLogin" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "item get --format json ExampleLogin --vault vault" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault" ]; then + echo "[ERROR] cannot use session tokens with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin" ]; then + echo "[ERROR] cannot use session tokens with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "account list --format=json" ]; then + echo "[ERROR] cannot use accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "signin --account account_uuid --raw" ]; then + echo "[ERROR] cannot sign in with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "signin --raw" ]; then + echo "[ERROR] cannot sign in with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "read --no-newline op://vault/item/field" ]; then + echo 'exampleField' +elif [ "$*" = "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field" ]; then + echo "[ERROR] cannot use session tokens with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field --account account_uuid" ]; then + echo "[ERROR] cannot use session tokens or accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "document get exampleDocument" ]; then + echo "[ERROR] cannot use document get with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument" ]; then + echo "[ERROR] cannot use document get with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument --vault vault" ]; then + echo "[ERROR] cannot use document get with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument --account account_uuid" ]; then + echo "[ERROR] cannot use document get with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument --vault vault --account account_uuid" ]; then + echo "[ERROR] cannot use document get with OP_CONNECT_HOST and OP_CONNECT_TOKEN set" 1>&2 + exit 1 +else + echo "[ERROR] 2020/01/01 00:00:00 unknown command \"$*\" for \"op\"" 1>&2 + exit 1 +fi +-- bin/op.cmd -- +@echo off +IF "%*" == "--version" ( + echo 2.0.0 +) ELSE IF "%*" == "item get --format json ExampleLogin --vault vault --account account_uuid" ( + echo.[ERROR] cannot use accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "item get --format json ExampleLogin --account account_uuid" ( + echo.[ERROR] cannot use accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault --account account_uuid" ( + echo.[ERROR] cannot use accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin --account account_uuid" ( + echo.[ERROR] cannot use accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "item get --format json ExampleLogin" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "item get --format json ExampleLogin --vault vault" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault" ( + echo.[ERROR] cannot use session tokens with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin" ( + echo.[ERROR] cannot use session tokens with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "account list --format=json" ( + echo.[ERROR] cannot use accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "signin --account account_uuid --raw" ( + echo.[ERROR] cannot sign in with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "signin --raw" ( + echo.[ERROR] cannot sign in with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 +) ELSE IF "%*" == "document get exampleDocument" ( + echo.[ERROR] cannot use document get with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument" ( + echo.[ERROR] cannot use document get with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument --vault vault" ( + echo.[ERROR] cannot use document get with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument --account account_uuid" ( + echo.[ERROR] cannot use document get with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument --vault vault --account account_uuid" ( + echo.[ERROR] cannot use document get with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "read --no-newline op://vault/item/field" ( + echo.exampleField +) ELSE IF "%*" == "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field" ( + echo.[ERROR] cannot use session tokens with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field --account account_uuid" ( + echo.[ERROR] cannot use session tokens or accounts with OP_CONNECT_HOST and OP_CONNECT_TOKEN set 1>&2 + exit /b 1 +) ELSE ( + echo "[ERROR] 2020/01/01 00:00:00 unknown command \"%*\" for \"op\"" 1>&2 + exit /b 1 +) +-- home/user/.config/chezmoi/chezmoi.toml -- +[onepassword] +mode = "connect" diff --git a/internal/cmd/testdata/scripts/onepassword2service.txtar b/internal/cmd/testdata/scripts/onepassword2service.txtar new file mode 100644 index 00000000000..7aae04a1976 --- /dev/null +++ b/internal/cmd/testdata/scripts/onepassword2service.txtar @@ -0,0 +1,198 @@ +[unix] chmod 755 bin/op +[windows] unix2dos bin/op.cmd + +mkhomedir + +# test that mode is properly set and reported +exec chezmoi execute-template '{{ .chezmoi.config.onepassword.mode }}' +stdout '^service$' + +# test failure without OP_SERVICE_ACCOUNT_TOKEN set +! exec chezmoi execute-template '{{ (onepassword "ExampleLogin").id }}' +stderr 'OP_SERVICE_ACCOUNT_TOKEN is not set' + +env OP_SERVICE_ACCOUNT_TOKEN=x + +# test onepassword template function +exec chezmoi execute-template '{{ (onepassword "ExampleLogin").id }}' +stdout '^wxcplh5udshnonkzg2n4qx262y$' + +# test onepassword template function with vault +exec chezmoi execute-template '{{ (onepassword "ExampleLogin" "vault").id }}' +stdout '^wxcplh5udshnonkzg2n4qx262y$' + +# test failure onepassword template function with vault and account +! exec chezmoi execute-template '{{ (onepassword "ExampleLogin" "vault" "account").id }}' +stderr '1Password account parameters cannot be used in service mode' + +# test onepassword template function with empty vault +exec chezmoi execute-template '{{ (onepassword "ExampleLogin" "").id }}' +stdout '^wxcplh5udshnonkzg2n4qx262y$' + +# test onepasswordDetailsFields template function +exec chezmoi execute-template '{{ (onepasswordDetailsFields "ExampleLogin").password.value }}' +stdout '^L8rm1JXJIE1b8YUDWq7h$' + +# test onepasswordItemFields template function +exec chezmoi execute-template '{{ (onepasswordItemFields "ExampleLogin").exampleLabel.value }}' +stdout exampleValue + +# test onepasswordRead template function +exec chezmoi execute-template '{{ onepasswordRead "op://vault/item/field" }}' +stdout exampleField + +# test failure onepasswordRead template function with account +! exec chezmoi execute-template '{{ onepasswordRead "op://vault/item/field" "account" }}' +stderr '1Password account parameters cannot be used in service mode' + +# test onepasswordDocument template function +exec chezmoi execute-template '{{ onepasswordDocument "exampleDocument" }}' +stdout 'OK-COMPUTER' + +# test onepasswordDocument template function with vault +exec chezmoi execute-template '{{ onepasswordDocument "exampleDocument" "vault" }}' +stdout 'OK-VAULT' + +# test onepasswordDocument template function with vault and account +! exec chezmoi execute-template '{{ onepasswordDocument "exampleDocument" "vault" "account" }}' +stderr '1Password account parameters cannot be used in service mode' + +# test onepasswordDocument template function with account +! exec chezmoi execute-template '{{ onepasswordDocument "exampleDocument" "" "account" }}' +stderr '1Password account parameters cannot be used in service mode' + +# test failure with OP_CONNECT_HOST and OP_CONNECT_TOKEN set +env OP_CONNECT_HOST=x +env OP_CONNECT_TOKEN=y +! exec chezmoi execute-template '{{ (onepassword "ExampleLogin").id }}' +stderr 'OP_CONNECT_HOST and OP_CONNECT_TOKEN' + +-- bin/op -- +#!/bin/sh + +if [ "$*" = "--version" ]; then + echo 2.0.0 +elif [ "$*" = "item get --format json ExampleLogin --vault vault --account account_uuid" ]; then + echo "[ERROR] cannot use accounts with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "item get --format json ExampleLogin --account account_uuid" ]; then + echo "[ERROR] cannot use accounts with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault --account account_uuid" ]; then + echo "[ERROR] cannot use accounts with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin --account account_uuid" ]; then + echo "[ERROR] cannot use accounts with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "item get --format json ExampleLogin" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "item get --format json ExampleLogin --vault vault" ]; then + echo '{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]}' +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault" ]; then + echo "[ERROR] cannot use session tokens with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken item get --format json ExampleLogin" ]; then + echo "[ERROR] cannot use session tokens with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "account list --format=json" ]; then + echo "[ERROR] cannot use accounts with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "signin --account account_uuid --raw" ]; then + echo "[ERROR] cannot sign in with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "signin --raw" ]; then + echo "[ERROR] cannot sign in with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "document get exampleDocument" ]; then + echo 'OK-COMPUTER' +elif [ "$*" = "document get exampleDocument --vault vault" ]; then + echo 'OK-VAULT' +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument" ]; then + echo "[ERROR] cannot use session tokens with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument --vault vault" ]; then + echo 'OK-VAULT' +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument --account account_uuid" ]; then + echo "[ERROR] cannot use accounts or session tokens with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken document get exampleDocument --vault vault --account account_uuid" ]; then + echo "[ERROR] cannot use accounts or session tokens with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "read --no-newline op://vault/item/field" ]; then + echo 'exampleField' +elif [ "$*" = "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field" ]; then + echo "[ERROR] cannot use session tokens with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +elif [ "$*" = "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field --account account_uuid" ]; then + echo "[ERROR] cannot use session tokens or accounts with OP_SERVICE_TOKEN set" 1>&2 + exit 1 +else + echo "[ERROR] 2020/01/01 00:00:00 unknown command \"$*\" for \"op\"" 1>&2 + exit 1 +fi +-- bin/op.cmd -- +@echo off +IF "%*" == "--version" ( + echo 2.0.0 +) ELSE IF "%*" == "item get --format json ExampleLogin --vault vault --account account_uuid" ( + echo.[ERROR] cannot use accounts with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "item get --format json ExampleLogin --account account_uuid" ( + echo.[ERROR] cannot use accounts with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault --account account_uuid" ( + echo.[ERROR] cannot use accounts with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin --account account_uuid" ( + echo.[ERROR] cannot use accounts with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "item get --format json ExampleLogin" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "item get --format json ExampleLogin --vault vault" ( + echo.{"id":"wxcplh5udshnonkzg2n4qx262y","title":"ExampleLogin","version":1,"vault":{"id":"tscpxgi6s7c662jtqn3vmw4n5a"},"category":"LOGIN","last_edited_by":"YO4UTYPAD3ZFBNZG5DVAZFBNZM","created_at":"2022-01-17T01:53:50Z","updated_at":"2022-01-17T01:55:35Z","sections":[{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"}],"fields":[{"id":"username","type":"STRING","purpose":"USERNAME","label":"username","value":"exampleuser "},{"id":"password","type":"CONCEALED","purpose":"PASSWORD","label":"password","value":"L8rm1JXJIE1b8YUDWq7h","password_details":{"strength":"EXCELLENT"}},{"id":"notesPlain","type":"STRING","purpose":"NOTES","label":"notesPlain"},{"id":"cqn7oda7wkcsar7rzcr52i2m3u","section":{"id":"Section_cdzjhg2jo7jylpyin2f5mbfnhm","label":"Related Items"},"type":"STRING","label":"exampleLabel","value":"exampleValue"}],"urls":[{"primary":true,"href":"https://www.example.com/"}]} +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin --vault vault" ( + echo.[ERROR] cannot use session tokens with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken item get --format json ExampleLogin" ( + echo.[ERROR] cannot use session tokens with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "account list --format=json" ( + echo.[ERROR] cannot use accounts with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "signin --account account_uuid --raw" ( + echo.[ERROR] cannot sign in with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "signin --raw" ( + echo.[ERROR] cannot sign in with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "document get exampleDocument" ( + echo.OK-COMPUTER +) ELSE IF "%*" == "document get exampleDocument --vault vault" ( + echo.OK-VAULT +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument" ( + echo.[ERROR] cannot use session tokens with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument --vault vault" ( + echo.[ERROR] cannot use session tokens with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument --account account_uuid" ( + echo.[ERROR] cannot use accounts or session tokens with OP_SERVICE_TOKEN set 1>&2 + exit 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken document get exampleDocument --vault vault --account account_uuid" ( + echo.[ERROR] cannot use accounts or session tokens with OP_SERVICE_TOKEN set 1>&2 + exit 1 +) ELSE IF "%*" == "read --no-newline op://vault/item/field" ( + echo.exampleField +) ELSE IF "%*" == "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field" ( + echo.[ERROR] cannot use session tokens with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE IF "%*" == "--session thisIsAFakeSessionToken read --no-newline op://vault/item/field --account account_uuid" ( + echo.[ERROR] cannot use session tokens or accounts with OP_SERVICE_TOKEN set 1>&2 + exit /b 1 +) ELSE ( + echo "[ERROR] 2020/01/01 00:00:00 unknown command \"%*\" for \"op\"" 1>&2 + exit /b 1 +) +-- home/user/.config/chezmoi/chezmoi.toml -- +[onepassword] +mode = "service" diff --git a/internal/cmd/testdata/scripts/options.txt b/internal/cmd/testdata/scripts/options.txtar similarity index 81% rename from internal/cmd/testdata/scripts/options.txt rename to internal/cmd/testdata/scripts/options.txtar index 2900a730283..33460d55070 100644 --- a/internal/cmd/testdata/scripts/options.txt +++ b/internal/cmd/testdata/scripts/options.txtar @@ -1,18 +1,18 @@ # test that --source flag is respected -chezmoi apply --source=~/.dotfiles +exec chezmoi apply --source=~/.dotfiles cmp $HOME/.file golden/.file chhome home2/user # test that --destination flag is respected mkdir tmp -chezmoi apply --destination=$WORK/tmp +exec chezmoi apply --destination=$WORK/tmp cmp tmp/.file golden/.file chhome home3/user # test that --config flag is respected -chezmoi apply --config=$HOME/.chezmoi.toml +exec chezmoi apply --config=$HOME/.chezmoi.toml cmp $HOME/tmp/.file golden/.file -- golden/.file -- diff --git a/internal/cmd/testdata/scripts/pass.txt b/internal/cmd/testdata/scripts/pass.txt deleted file mode 100644 index 079ee0c05b3..00000000000 --- a/internal/cmd/testdata/scripts/pass.txt +++ /dev/null @@ -1,34 +0,0 @@ -[!windows] chmod 755 bin/pass -[windows] unix2dos bin/pass.cmd - -# test pass template function -chezmoi execute-template '{{ pass "misc/example.com" }}' -stdout examplepassword -! stdout 'second line' - -# test pass template function -chezmoi execute-template '{{ passRaw "misc/example.com" }}' -stdout 'second line' - --- bin/pass -- -#!/bin/sh - -case "$*" in -"show misc/example.com") - echo "examplepassword" - echo "second line" - ;; -*) - echo "pass: invalid command: $*" - exit 1 -esac --- bin/pass.cmd -- -@echo off -IF "%*" == "show misc/example.com" ( - echo "examplepassword" - echo "second line" - exit /b 0 -) ELSE ( - echo pass: invalid command: %* - exit /b 1 -) diff --git a/internal/cmd/testdata/scripts/pass.txtar b/internal/cmd/testdata/scripts/pass.txtar new file mode 100644 index 00000000000..076412168ed --- /dev/null +++ b/internal/cmd/testdata/scripts/pass.txtar @@ -0,0 +1,41 @@ +[unix] chmod 755 bin/pass +[windows] unix2dos bin/pass.cmd +[windows] unix2dos golden/pass-raw + +# test pass template function +exec chezmoi execute-template '{{ pass "misc/example.com" }}' +stdout ^examplepassword$ + +# test passFields template function +exec chezmoi execute-template '{{ (passFields "misc/example.com").login }}' +stdout ^examplelogin$ + +# test pass template function +exec chezmoi execute-template '{{ passRaw "misc/example.com" }}' +cmp stdout golden/pass-raw + +-- bin/pass -- +#!/bin/sh + +case "$*" in +"show misc/example.com") + echo "examplepassword" + echo "login: examplelogin" + ;; +*) + echo "pass: invalid command: $*" + exit 1 +esac +-- bin/pass.cmd -- +@echo off +IF "%*" == "show misc/example.com" ( + echo.examplepassword + echo.login: examplelogin + exit /b 0 +) ELSE ( + echo pass: invalid command: %* + exit /b 1 +) +-- golden/pass-raw -- +examplepassword +login: examplelogin diff --git a/internal/cmd/testdata/scripts/passhole.txtar b/internal/cmd/testdata/scripts/passhole.txtar new file mode 100644 index 00000000000..977589967b1 --- /dev/null +++ b/internal/cmd/testdata/scripts/passhole.txtar @@ -0,0 +1,33 @@ +[unix] chmod 755 bin/ph +[windows] unix2dos bin/ph.cmd + +# test passhole template function +stdin golden/stdin +exec chezmoi execute-template --no-tty '{{ passhole "example.com" "password" }}' +stdout examplepassword + +-- bin/ph -- +#!/bin/sh +case "$*" in +"--version") + echo "1.9.9" + ;; +"--password - show --field password example.com") + echo "examplepassword" + ;; +*) + echo "ph: error: argument command: invalid choice:" + exit 1 +esac +-- bin/ph.cmd -- +@echo off +IF "%*" == "--version" ( + echo 1.9.9 +) ELSE IF "%*" == "--password - show --field password example.com" ( + echo examplepassword +) ELSE ( + echo ph: error: argument command: invalid choice: + exit /b 1 +) +-- golden/stdin -- +fakepassword diff --git a/internal/cmd/testdata/scripts/plugin.txtar b/internal/cmd/testdata/scripts/plugin.txtar new file mode 100644 index 00000000000..02dddfdba80 --- /dev/null +++ b/internal/cmd/testdata/scripts/plugin.txtar @@ -0,0 +1,19 @@ +[unix] chmod 755 bin/chezmoi-plugin + +# test that chezmoi returns unknown command errors for unknown commands +! exec chezmoi unknown +stderr 'unknown command' + +# test that chezmoi executes plugins +exec chezmoi plugin +stdout CHEZMOI_COMMAND=plugin +stdout CHEZMOI_SOURCE_DIR=${CHEZMOISOURCEDIR@R} + +-- bin/chezmoi-plugin -- +#!/bin/sh + +echo CHEZMOI_COMMAND=${CHEZMOI_COMMAND} +echo CHEZMOI_SOURCE_DIR=${CHEZMOI_SOURCE_DIR} +-- bin/chezmoi-plugin.cmd -- +@echo CHEZMOI_COMMAND=%CHEZMOI_COMMAND% +@echo CHEZMOI_SOURCE_DIR=%CHEZMOI_SOURCE_DIR% diff --git a/internal/cmd/testdata/scripts/purge.txt b/internal/cmd/testdata/scripts/purge.txtar similarity index 82% rename from internal/cmd/testdata/scripts/purge.txt rename to internal/cmd/testdata/scripts/purge.txtar index dfcfd0815cc..55645d5098e 100644 --- a/internal/cmd/testdata/scripts/purge.txt +++ b/internal/cmd/testdata/scripts/purge.txtar @@ -2,19 +2,19 @@ mksourcedir # test that chezmoi purge purges the source dir exists $CHEZMOISOURCEDIR -chezmoi purge --force +exec chezmoi purge --force ! exists $CHEZMOISOURCEDIR chhome home2/user # test that chezmoi purge purges the config dir exists $CHEZMOICONFIGDIR -chezmoi purge --force +exec chezmoi purge --force ! exists $CHEZMOICONFIGDIR # test that chezmoi purge purges the cache dir mkdir $HOME/.cache/chezmoi -chezmoi purge --force +exec chezmoi purge --force ! exists $HOME/.cache/chezmoi -- home2/user/.config/chezmoi/chezmoi.toml -- diff --git a/internal/cmd/testdata/scripts/rbw.txtar b/internal/cmd/testdata/scripts/rbw.txtar new file mode 100644 index 00000000000..b59229eafa6 --- /dev/null +++ b/internal/cmd/testdata/scripts/rbw.txtar @@ -0,0 +1,158 @@ +[unix] chmod 755 bin/rbw +[windows] unix2dos bin/rbw.cmd + +# test rbw template function +exec chezmoi execute-template '{{ (rbw "test-entry").data.password }}' +stdout ^hunter2$ + +# test rbw template function with extra args +exec chezmoi execute-template '{{ (rbw "test-entry" "--folder" "my-folder").data.password }}' +stdout ^correcthorsebatterystaple$ + +# test rbwFields template function +exec chezmoi execute-template '{{ (rbwFields "test-entry").something.value }}' +stdout ^secret$ + +# test rbwFields template function with extra args +exec chezmoi execute-template '{{ (rbwFields "test-entry" "--folder" "my-folder").something.value }}' +stdout ^enigma$ + +-- bin/rbw -- +#!/bin/sh + +case "$*" in +"get --raw test-entry") + cat < .file -chezmoi add $HOME${/}.symlink +exec chezmoi add $HOME${/}.symlink cmp $CHEZMOISOURCEDIR/home/symlink_dot_symlink golden/symlink_dot_symlink [!exec:git] skip 'git not found in $PATH' @@ -19,7 +19,7 @@ exec git -C $HOME/repo add . exec git -C $HOME/repo commit -m 'Initial commit' # test that chezmoi init uses .chezmoiroot -chezmoi init --apply file://$HOME/repo +exec chezmoi init --apply file://$HOME/repo exists $CHEZMOICONFIGDIR/chezmoi.toml cmp $HOME/.file golden/.file diff --git a/internal/cmd/testdata/scripts/runscriptdir_unix.txt b/internal/cmd/testdata/scripts/runscriptdir_unix.txtar similarity index 54% rename from internal/cmd/testdata/scripts/runscriptdir_unix.txt rename to internal/cmd/testdata/scripts/runscriptdir_unix.txtar index fd4f9c89838..4c20f0d15c1 100644 --- a/internal/cmd/testdata/scripts/runscriptdir_unix.txt +++ b/internal/cmd/testdata/scripts/runscriptdir_unix.txtar @@ -1,14 +1,14 @@ [windows] skip 'UNIX only' [!umask:022] skip -chezmoi apply +exec chezmoi apply cmpenv stdout golden/apply env $=$ -chezmoi dump -cmp stdout golden/dump +exec chezmoi dump +cmp stdout golden/dump.json -chezmoi archive --output=archive.tar +exec chezmoi archive --output=archive.tar exec tar -tf archive.tar [openbsd] cmp stdout golden/archive-openbsd [!openbsd] cmp stdout golden/archive @@ -17,24 +17,25 @@ exec tar -tf archive.tar $HOME${/}dir -- golden/archive -- dir/ -dir/script +dir/script.sh -- golden/archive-openbsd -- dir -dir/script --- golden/dump -- +dir/script.sh +-- golden/dump.json -- { "dir": { "type": "dir", "name": "dir", "perm": 493 }, - "dir/script": { + "dir/script.sh": { "type": "script", - "name": "dir/script", - "contents": "#!/bin/sh\n\npwd\n" + "name": "dir/script.sh", + "contents": "#!/bin/sh\n\npwd\n", + "condition": "always" } } --- home/user/.local/share/chezmoi/dir/run_script -- +-- home/user/.local/share/chezmoi/dir/run_script.sh -- #!/bin/sh pwd diff --git a/internal/cmd/testdata/scripts/script.txt b/internal/cmd/testdata/scripts/script.txtar similarity index 73% rename from internal/cmd/testdata/scripts/script.txt rename to internal/cmd/testdata/scripts/script.txtar index 4a6b9b2f0d4..fee7d007e61 100644 --- a/internal/cmd/testdata/scripts/script.txt +++ b/internal/cmd/testdata/scripts/script.txtar @@ -1,10 +1,10 @@ -[!windows] chmod 755 bin/perl -[!windows] chmod 755 bin/python -[!windows] chmod 755 bin/ruby +[unix] chmod 755 bin/perl +[unix] chmod 755 bin/python3 +[unix] chmod 755 bin/ruby [windows] unix2dos golden/stdout -# test that chezmoi apply uses python and ruby from $PATH instead of the system Python and Ruby -chezmoi apply +# test that chezmoi apply uses python3 and ruby from $PATH instead of the system Python and Ruby +exec chezmoi apply cmp stdout golden/stdout -- bin/perl -- @@ -13,11 +13,11 @@ cmp stdout golden/stdout echo "Hello from fake Perl" -- bin/perl.bat -- @echo Hello from fake Perl --- bin/python -- +-- bin/python3 -- #!/bin/sh echo "Hello from fake Python" --- bin/python.bat -- +-- bin/python3.bat -- @echo Hello from fake Python -- bin/ruby -- #!/bin/sh @@ -34,7 +34,7 @@ Hello from fake Ruby print("Hello from Perl\n") -- home/user/.local/share/chezmoi/run_python_script.py -- -#!/usr/bin/env python +#!/usr/bin/env python3 print("Hello from Python\n") -- home/user/.local/share/chezmoi/run_ruby_script.rb -- diff --git a/internal/cmd/testdata/scripts/script_unix.txt b/internal/cmd/testdata/scripts/script_unix.txt deleted file mode 100644 index 190a6018bef..00000000000 --- a/internal/cmd/testdata/scripts/script_unix.txt +++ /dev/null @@ -1,70 +0,0 @@ -[windows] skip 'UNIX only' - -# test that chezmoi status prints that it will run the script -chezmoi status -cmp stdout golden/status - -# test the chezmoi diff prints the script -chezmoi diff -cmp stdout golden/diff - -# test that chezmoi apply runs the script -chezmoi apply --force -stdout ${HOME@R} - -# test that chezmoi status prints that it will run the script again -chezmoi status -cmp stdout golden/status - -# test that chezmoi apply runs the script even if it has run before -chezmoi apply --force -stdout ${HOME@R} - -# test that chezmoi dump includes the script -chezmoi dump -cmp stdout golden/dump.json - -# test that chezmoi managed includes the script -chezmoi managed --include=scripts -cmpenv stdout golden/managed - -# test that chezmoi cat writes the contents of the script -chezmoi cat $HOME${/}script -cmp stdout golden/script - -# test that chezmoi archive includes the script in the archive -chezmoi archive --format=tar --gzip --output=archive.tar.gz -exec tar -tzf archive.tar.gz -cmp stdout golden/archive - --- golden/archive -- -script --- golden/diff -- -diff --git a/script b/script -index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..f9103e018df1bbc178e66b46d8f133f49c85225d 100755 ---- a/script -+++ b/script -@@ -0,0 +1,3 @@ -+#!/bin/sh -+ -+pwd --- golden/dump.json -- -{ - "script": { - "type": "script", - "name": "script", - "contents": "#!/bin/sh\n\npwd\n" - } -} --- golden/managed -- -script --- golden/script -- -#!/bin/sh - -pwd --- golden/status -- - R script --- home/user/.local/share/chezmoi/run_script -- -#!/bin/sh - -pwd diff --git a/internal/cmd/testdata/scripts/script_unix.txtar b/internal/cmd/testdata/scripts/script_unix.txtar new file mode 100644 index 00000000000..701271e5165 --- /dev/null +++ b/internal/cmd/testdata/scripts/script_unix.txtar @@ -0,0 +1,82 @@ +[windows] skip 'UNIX only' + +# test that chezmoi status prints that it will run the script +exec chezmoi status +cmp stdout golden/status + +# test that chezmoi diff prints the script +exec chezmoi diff +cmp stdout golden/diff.diff + +# test that chezmoi diff --script-contents=false prints the script name but not its contents +exec chezmoi diff --script-contents=false +cmp stdout golden/diff-no-script-contents.diff + +# test that chezmoi apply runs the script +exec chezmoi apply --force +stdout ${HOME@R} + +# test that chezmoi status prints that it will run the script again +exec chezmoi status +cmp stdout golden/status + +# test that chezmoi apply runs the script even if it has run before +exec chezmoi apply --force +stdout ${HOME@R} + +# test that chezmoi dump includes the script +exec chezmoi dump +cmp stdout golden/dump.json + +# test that chezmoi managed includes the script +exec chezmoi managed --include=scripts +cmpenv stdout golden/managed + +# test that chezmoi cat writes the contents of the script +exec chezmoi cat $HOME${/}script.sh +cmp stdout golden/script.sh + +# test that chezmoi archive includes the script in the archive +exec chezmoi archive --format=tar --gzip --output=archive.tar.gz +exec tar -tzf archive.tar.gz +cmp stdout golden/archive + +-- golden/archive -- +script.sh +-- golden/diff-no-script-contents.diff -- +diff --git a/script.sh b/script.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +--- /dev/null ++++ b/script.sh +-- golden/diff.diff -- +diff --git a/script.sh b/script.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..f9103e018df1bbc178e66b46d8f133f49c85225d +--- /dev/null ++++ b/script.sh +@@ -0,0 +1,3 @@ ++#!/bin/sh ++ ++pwd +-- golden/dump.json -- +{ + "script.sh": { + "type": "script", + "name": "script.sh", + "contents": "#!/bin/sh\n\npwd\n", + "condition": "always" + } +} +-- golden/managed -- +script.sh +-- golden/script.sh -- +#!/bin/sh + +pwd +-- golden/status -- + R script.sh +-- home/user/.local/share/chezmoi/run_script.sh -- +#!/bin/sh + +pwd diff --git a/internal/cmd/testdata/scripts/script_windows.txt b/internal/cmd/testdata/scripts/script_windows.txtar similarity index 63% rename from internal/cmd/testdata/scripts/script_windows.txt rename to internal/cmd/testdata/scripts/script_windows.txtar index 5e728b700fd..c492b286a6f 100644 --- a/internal/cmd/testdata/scripts/script_windows.txt +++ b/internal/cmd/testdata/scripts/script_windows.txtar @@ -1,15 +1,15 @@ -[!windows] skip 'Windows only' +[unix] skip 'Windows only' -chezmoi apply --force +exec chezmoi apply --force stdout evidence -chezmoi dump +exec chezmoi dump cmp stdout golden/dump.json -chezmoi managed --include=scripts +exec chezmoi managed --include=scripts cmpenv stdout golden/managed -chezmoi archive --gzip --output=archive.tar.gz +exec chezmoi archive --gzip --output=archive.tar.gz exec tar -tzf archive.tar.gz [windows] unix2dos golden/archive cmp stdout golden/archive @@ -21,7 +21,8 @@ script.cmd "script.cmd": { "type": "script", "name": "script.cmd", - "contents": "echo evidence\n" + "contents": "echo evidence\n", + "condition": "always" } } -- golden/managed -- diff --git a/internal/cmd/testdata/scripts/scriptenv.txtar b/internal/cmd/testdata/scripts/scriptenv.txtar new file mode 100644 index 00000000000..083f73c462a --- /dev/null +++ b/internal/cmd/testdata/scripts/scriptenv.txtar @@ -0,0 +1,33 @@ +[windows] skip 'UNIX only' + +# test that chezmoi sets environment variables for scripts +exec chezmoi apply +stdout ^WORK=${WORK@R}$ +[darwin] stdout ^CHEZMOI_OS=darwin$ +[linux] stdout ^CHEZMOI_OS=linux$ +stdout ^CHEZMOI_SOURCE_DIR=${CHEZMOISOURCEDIR@R}/home$ +stdout ^CHEZMOI_VERBOSE=$ +stdout ^SCRIPTENV_KEY=SCRIPTENV_VALUE$ + +# test that chezmoi passes along --verbose in scripts +exec chezmoi apply --verbose +stdout ^WORK=${WORK@R}$ +[darwin] stdout ^CHEZMOI_OS=darwin$ +[linux] stdout ^CHEZMOI_OS=linux$ +stdout ^CHEZMOI_SOURCE_DIR=${CHEZMOISOURCEDIR@R}/home$ +stdout ^CHEZMOI_VERBOSE=1$ +stdout ^SCRIPTENV_KEY=SCRIPTENV_VALUE$ + +-- home/user/.config/chezmoi/chezmoi.toml -- +[scriptEnv] + SCRIPTENV_KEY = "SCRIPTENV_VALUE" +-- home/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home/user/.local/share/chezmoi/home/run_print-variable.sh -- +#!/bin/sh + +echo "WORK=${WORK}" +echo "CHEZMOI_OS=${CHEZMOI_OS}" +echo "CHEZMOI_SOURCE_DIR=${CHEZMOI_SOURCE_DIR}" +echo "SCRIPTENV_KEY=${SCRIPTENV_KEY}" +echo "CHEZMOI_VERBOSE=${CHEZMOI_VERBOSE}" diff --git a/internal/cmd/testdata/scripts/scriptinterpreters_windows.txt b/internal/cmd/testdata/scripts/scriptinterpreters_windows.txtar similarity index 79% rename from internal/cmd/testdata/scripts/scriptinterpreters_windows.txt rename to internal/cmd/testdata/scripts/scriptinterpreters_windows.txtar index b3f24109c74..e55cb9fa480 100644 --- a/internal/cmd/testdata/scripts/scriptinterpreters_windows.txt +++ b/internal/cmd/testdata/scripts/scriptinterpreters_windows.txtar @@ -1,24 +1,24 @@ -[!windows] skip 'Windows only' +[unix] skip 'Windows only' # test that chezmoi apply runs Batch scripts -chezmoi apply +exec chezmoi apply unix2dos golden/stdout # normalize line endings before comparison cmp stdout golden/stdout chhome home2/user # test that chezmoi apply runs PowerShell scripts -chezmoi apply +exec chezmoi apply cmp stdout golden/stdout2 # PowerShell already uses UNIX line endings chhome home3/user # test that interpreters can be overridden -chezmoi apply +exec chezmoi apply unix2dos golden/stdout3 # normalize line endings before comparison cmp stdout golden/stdout3 --- bin/fake-python.bat -- +-- bin/fake-python3.bat -- @echo Hello from fake Python -- golden/stdout -- Hello from Batch (.bat) @@ -35,10 +35,10 @@ Hello from fake Python Write-Host 'Hello from PowerShell' -- home3/user/.config/chezmoi/chezmoi.toml -- [interpreters.py] - command = "fake-python" + command = "fake-python3" -- home3/user/.local/share/chezmoi/run_python_script.py -- -#!/usr/bin/env python +#!/usr/bin/env python3 -# this should never be executed as the interpreter is overriden with -# fake-python.bat in the config file +# this should never be executed as the interpreter is overridden with +# fake-python3.bat in the config file fail() diff --git a/internal/cmd/testdata/scripts/scriptinterpreterstemplate.txt b/internal/cmd/testdata/scripts/scriptinterpreterstemplate.txt deleted file mode 100644 index 200ab622287..00000000000 --- a/internal/cmd/testdata/scripts/scriptinterpreterstemplate.txt +++ /dev/null @@ -1,10 +0,0 @@ -[!exec:python] skip 'python not found in $PATH' - -# test python scripts -chezmoi apply -stdout 'Hello from Python' - --- home/user/.local/share/chezmoi/run_python.py.tmpl -- -#!/usr/bin/env python - -print({{ "Hello from Python\n" | quote }}) diff --git a/internal/cmd/testdata/scripts/scriptinterpreterstemplate.txtar b/internal/cmd/testdata/scripts/scriptinterpreterstemplate.txtar new file mode 100644 index 00000000000..ec7023e9d1c --- /dev/null +++ b/internal/cmd/testdata/scripts/scriptinterpreterstemplate.txtar @@ -0,0 +1,10 @@ +[!exec:python3] skip 'python3 not found in $PATH' + +# test python3 scripts +exec chezmoi apply +stdout 'Hello from Python' + +-- home/user/.local/share/chezmoi/run_python3.py.tmpl -- +#!/usr/bin/env python3 + +print({{ "Hello from Python\n" | quote }}) diff --git a/internal/cmd/testdata/scripts/scriptonce_unix.txt b/internal/cmd/testdata/scripts/scriptonce_unix.txtar similarity index 62% rename from internal/cmd/testdata/scripts/scriptonce_unix.txt rename to internal/cmd/testdata/scripts/scriptonce_unix.txtar index c1957bbe552..4f1974105c4 100644 --- a/internal/cmd/testdata/scripts/scriptonce_unix.txt +++ b/internal/cmd/testdata/scripts/scriptonce_unix.txtar @@ -1,55 +1,56 @@ [windows] skip 'UNIX only' # test that chezmoi status prints that it will run the script -chezmoi status +exec chezmoi status cmp stdout golden/status # test that chezmoi diff includes the script -chezmoi diff -cmp stdout golden/diff +exec chezmoi diff +cmp stdout golden/diff.diff # test that chezmoi apply runs the script -chezmoi apply --force +exec chezmoi apply --force stdout ${HOME@R} # test that the script is recorded in the state -chezmoi state dump +exec chezmoi state dump stdout bb29fcd5733098d4e391d85d487d84d1d64cf42eae34b53951ae470b98c9ca8d # sha256sum of script contents # test that chezmoi diff no longer includes the script -chezmoi diff +exec chezmoi diff ! stdout . # test that chezmoi status will not print that it will run the script -chezmoi status +exec chezmoi status ! stdout . # test that chezmoi apply does not run the script a second time and does not prompt -chezmoi apply +exec chezmoi apply ! stdout ${HOME@R} # test that chezmoi apply after the script is modified runs the script a second time and does not prompt -edit $CHEZMOISOURCEDIR/run_once_script -chezmoi apply +edit $CHEZMOISOURCEDIR/run_once_script.sh +exec chezmoi apply stdout ${HOME@R} # test that resetting the state causes the next chezmoi apply to run the script -chezmoi state reset --force -chezmoi apply --force +exec chezmoi state reset --force +exec chezmoi apply --force stdout ${HOME@R} --- golden/diff -- -diff --git a/script b/script -index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..f9103e018df1bbc178e66b46d8f133f49c85225d 100755 ---- a/script -+++ b/script +-- golden/diff.diff -- +diff --git a/script.sh b/script.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..f9103e018df1bbc178e66b46d8f133f49c85225d +--- /dev/null ++++ b/script.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +pwd -- golden/status -- - R script --- home/user/.local/share/chezmoi/run_once_script -- + R script.sh +-- home/user/.local/share/chezmoi/run_once_script.sh -- #!/bin/sh pwd diff --git a/internal/cmd/testdata/scripts/scriptonce_windows.txt b/internal/cmd/testdata/scripts/scriptonce_windows.txtar similarity index 76% rename from internal/cmd/testdata/scripts/scriptonce_windows.txt rename to internal/cmd/testdata/scripts/scriptonce_windows.txtar index 1e2110d0c24..e29e16ca7ce 100644 --- a/internal/cmd/testdata/scripts/scriptonce_windows.txt +++ b/internal/cmd/testdata/scripts/scriptonce_windows.txtar @@ -1,28 +1,28 @@ -[!windows] skip 'Windows only' +[unix] skip 'Windows only' # test that chezmoi status prints that it will run the script -chezmoi status +exec chezmoi status cmp stdout golden/status # test that chezmoi apply runs the script -chezmoi apply --force +exec chezmoi apply --force stdout ${HOME@R} # test that the script is recorded in the state -chezmoi state dump +exec chezmoi state dump stdout 4716eebb443f263affd831da48c4af62715fab9dc9e6a813f8bf8992aa53c5b4 # sha256sum of script contents # test that chezmoi status will not print that it will run the script -chezmoi status +exec chezmoi status ! stdout . # test that chezmoi apply does not run the script a second time -chezmoi apply --force +exec chezmoi apply --force ! stdout ${HOME@R} # test that resetting the state causes the next chezmoi apply to run the script -chezmoi state reset --force -chezmoi apply --force +exec chezmoi state reset --force +exec chezmoi apply --force stdout ${HOME@R} -- golden/status -- diff --git a/internal/cmd/testdata/scripts/scriptonchange_unix.txt b/internal/cmd/testdata/scripts/scriptonchange_unix.txtar similarity index 82% rename from internal/cmd/testdata/scripts/scriptonchange_unix.txt rename to internal/cmd/testdata/scripts/scriptonchange_unix.txtar index 80755c38dc5..2e31208abfc 100644 --- a/internal/cmd/testdata/scripts/scriptonchange_unix.txt +++ b/internal/cmd/testdata/scripts/scriptonchange_unix.txtar @@ -3,35 +3,35 @@ # test that chezmoi apply runs onchange scripts the first time mkdir $CHEZMOISOURCEDIR cp golden/script-one.sh $CHEZMOISOURCEDIR/run_onchange_script.sh -chezmoi apply +exec chezmoi apply stdout one -chezmoi state get --bucket=entryState --key=$HOME/script.sh +exec chezmoi state get --bucket=entryState --key=$HOME/script.sh cmp stdout golden/script-one-state.json # test that chezmoi apply does not run onchange scripts when their contents are not changed -chezmoi apply +exec chezmoi apply ! stdout . # test that chezmoi status does not print that it will run onchange scripts when their contents are not changed -chezmoi status +exec chezmoi status ! stdout . # test that chezmoi status does print that it will run onchange scripts when their contents are changed cp golden/script-two.sh $CHEZMOISOURCEDIR/run_onchange_script.sh -chezmoi status +exec chezmoi status cmp stdout golden/status # test that chezmoi apply runs onchange scripts when their contents are changed -chezmoi apply +exec chezmoi apply stdout two -chezmoi state get --bucket=entryState --key=$HOME/script.sh +exec chezmoi state get --bucket=entryState --key=$HOME/script.sh cmp stdout golden/script-two-state.json # test that chezmoi apply runs onchange scripts when their contents are reverted to a previous state cp golden/script-one.sh $CHEZMOISOURCEDIR/run_onchange_script.sh -chezmoi apply +exec chezmoi apply stdout one -chezmoi state get --bucket=entryState --key=$HOME/script.sh +exec chezmoi state get --bucket=entryState --key=$HOME/script.sh cmp stdout golden/script-one-state.json -- golden/script-one-state.json -- @@ -39,15 +39,15 @@ cmp stdout golden/script-one-state.json "type": "script", "contentsSHA256": "a07f0271151ee0271ed379ebbddc5ef49d0f625417c8fe23254179e56f98d2df" } +-- golden/script-one.sh -- +#!/bin/sh + +echo one -- golden/script-two-state.json -- { "type": "script", "contentsSHA256": "7c8d714586cecf4f0ffb735ad10334df98428bc5282c0d0a6b78f5c074365159" } --- golden/script-one.sh -- -#!/bin/sh - -echo one -- golden/script-two.sh -- #!/bin/sh diff --git a/internal/cmd/testdata/scripts/scriptorder_unix.txt b/internal/cmd/testdata/scripts/scriptorder_unix.txt deleted file mode 100644 index e0e1a04e471..00000000000 --- a/internal/cmd/testdata/scripts/scriptorder_unix.txt +++ /dev/null @@ -1,24 +0,0 @@ -[windows] skip 'UNIX only' - -# test that chezmoi apply runs scripts in the correct order -symlink home/user/.local/share/chezmoi/run_before_00-before -> .script -symlink home/user/.local/share/chezmoi/run_before_99-before -> .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_after_00-after -> .script -symlink home/user/.local/share/chezmoi/run_after_99-after -> .script -chezmoi apply --force -cmp stdout golden/apply - --- golden/apply -- -00-before -99-before -00 -99 -00-after -99-after --- home/user/.local/share/chezmoi/.script -- -#!/bin/sh - -basename=$(basename $0) -echo ${basename##*.} diff --git a/internal/cmd/testdata/scripts/scriptorder_unix.txtar b/internal/cmd/testdata/scripts/scriptorder_unix.txtar new file mode 100644 index 00000000000..008526554e6 --- /dev/null +++ b/internal/cmd/testdata/scripts/scriptorder_unix.txtar @@ -0,0 +1,37 @@ +[windows] skip 'UNIX only' + +# test that chezmoi apply runs scripts in the correct order +symlink home/user/.local/share/chezmoi/.chezmoiscripts/run_before_00-chezmoiscripts-before -> ../.script.sh +symlink home/user/.local/share/chezmoi/.chezmoiscripts/run_before_99-chezmoiscripts-before -> ../.script.sh +symlink home/user/.local/share/chezmoi/.chezmoiscripts/run_00-chezmoiscripts -> ../.script.sh +symlink home/user/.local/share/chezmoi/.chezmoiscripts/run_99-chezmoiscripts -> ../.script.sh +symlink home/user/.local/share/chezmoi/.chezmoiscripts/run_after_00-chezmoiscripts-after -> ../.script.sh +symlink home/user/.local/share/chezmoi/.chezmoiscripts/run_after_99-chezmoiscripts-after -> ../.script.sh +symlink home/user/.local/share/chezmoi/run_before_00-before -> .script.sh +symlink home/user/.local/share/chezmoi/run_before_99-before -> .script.sh +symlink home/user/.local/share/chezmoi/run_00 -> .script.sh +symlink home/user/.local/share/chezmoi/run_99 -> .script.sh +symlink home/user/.local/share/chezmoi/run_after_00-after -> .script.sh +symlink home/user/.local/share/chezmoi/run_after_99-after -> .script.sh +exec chezmoi apply --force +cmp stdout golden/apply + +-- golden/apply -- +00-chezmoiscripts-before +99-chezmoiscripts-before +00-before +99-before +00-chezmoiscripts +99-chezmoiscripts +00 +99 +00-chezmoiscripts-after +99-chezmoiscripts-after +00-after +99-after +-- home/user/.local/share/chezmoi/.chezmoiscripts/.keep -- +-- home/user/.local/share/chezmoi/.script.sh -- +#!/bin/sh + +basename=$(basename $0) +echo ${basename##*.} diff --git a/internal/cmd/testdata/scripts/scriptorder_windows.txt b/internal/cmd/testdata/scripts/scriptorder_windows.txtar similarity index 93% rename from internal/cmd/testdata/scripts/scriptorder_windows.txt rename to internal/cmd/testdata/scripts/scriptorder_windows.txtar index 9f85f22fe4a..08b31ad760c 100644 --- a/internal/cmd/testdata/scripts/scriptorder_windows.txt +++ b/internal/cmd/testdata/scripts/scriptorder_windows.txtar @@ -1,4 +1,4 @@ -[!windows] skip 'Windows only' +[unix] skip 'Windows only' unix2dos golden/apply @@ -9,7 +9,7 @@ symlink home/user/.local/share/chezmoi/run_00.cmd -> .script.cmd symlink home/user/.local/share/chezmoi/run_99.cmd -> .script.cmd symlink home/user/.local/share/chezmoi/run_after_00-after.cmd -> .script.cmd symlink home/user/.local/share/chezmoi/run_after_99-after.cmd -> .script.cmd -chezmoi apply --force +exec chezmoi apply --force cmp stdout golden/apply -- golden/apply -- diff --git a/internal/cmd/testdata/scripts/scriptperl.txt b/internal/cmd/testdata/scripts/scriptperl.txtar similarity index 89% rename from internal/cmd/testdata/scripts/scriptperl.txt rename to internal/cmd/testdata/scripts/scriptperl.txtar index f13c5e0c339..699554ff002 100644 --- a/internal/cmd/testdata/scripts/scriptperl.txt +++ b/internal/cmd/testdata/scripts/scriptperl.txtar @@ -1,6 +1,6 @@ [!exec:perl] skip 'perl not found in $PATH' -chezmoi apply +exec chezmoi apply stdout 'Hello from Perl' -- home/user/.local/share/chezmoi/run_script.pl -- diff --git a/internal/cmd/testdata/scripts/scriptpython.txt b/internal/cmd/testdata/scripts/scriptpython.txtar similarity index 54% rename from internal/cmd/testdata/scripts/scriptpython.txt rename to internal/cmd/testdata/scripts/scriptpython.txtar index 5246b600d1b..293e7282600 100644 --- a/internal/cmd/testdata/scripts/scriptpython.txt +++ b/internal/cmd/testdata/scripts/scriptpython.txtar @@ -1,9 +1,9 @@ -[!exec:python] skip 'python not found in $PATH' +[!exec:python3] skip 'python3 not found in $PATH' -chezmoi apply +exec chezmoi apply stdout 'Hello from Python' -- home/user/.local/share/chezmoi/run_script.py -- -#!/usr/bin/env python +#!/usr/bin/env python3 print("Hello from Python\n") diff --git a/internal/cmd/testdata/scripts/scriptruby.txt b/internal/cmd/testdata/scripts/scriptruby.txtar similarity index 89% rename from internal/cmd/testdata/scripts/scriptruby.txt rename to internal/cmd/testdata/scripts/scriptruby.txtar index 1d23ca424c8..4a2bc14dcde 100644 --- a/internal/cmd/testdata/scripts/scriptruby.txt +++ b/internal/cmd/testdata/scripts/scriptruby.txtar @@ -1,6 +1,6 @@ [!exec:ruby] skip 'ruby not found in $PATH' -chezmoi apply +exec chezmoi apply stdout 'Hello from Ruby' -- home/user/.local/share/chezmoi/run_script.rb -- diff --git a/internal/cmd/testdata/scripts/scriptsdir_unix.txtar b/internal/cmd/testdata/scripts/scriptsdir_unix.txtar new file mode 100644 index 00000000000..b83266a2ad4 --- /dev/null +++ b/internal/cmd/testdata/scripts/scriptsdir_unix.txtar @@ -0,0 +1,40 @@ +[windows] skip 'UNIX only' + +# test that chezmoi apply runs scripts in .chezmoiscripts +exec chezmoi apply +cmp stdout golden/apply + +chhome home2/user + +# test that chezmoi apply fails if .chezmoiscripts contains a non-script +! exec chezmoi apply +stderr ${CHEZMOISOURCEDIR@R}/\.chezmoiscripts/dot_file:\snot\sa\sscript$ + +chhome home3/user + +# test that chezmoi apply fails if .chezmoiscripts contains duplicate targets +! exec chezmoi apply +stderr \.chezmoiscripts/script\.sh:\sinconsistent\sstate + +chhome home4/user + +# test that chezmoi apply fails if .chezmoiscripts contains any .chezmoi* files +! exec chezmoi apply +stderr 'not allowed in \.chezmoiscripts directory' + +-- golden/apply -- +script +script in subdir +-- home/user/.local/share/chezmoi/.chezmoiscripts/.ignore -- +-- home/user/.local/share/chezmoi/.chezmoiscripts/run_script.sh -- +#!/bin/sh + +echo script +-- home/user/.local/share/chezmoi/.chezmoiscripts/subdir/run_script.sh -- +#!/bin/sh + +echo script in subdir +-- home2/user/.local/share/chezmoi/.chezmoiscripts/dot_file -- +-- home3/user/.local/share/chezmoi/.chezmoiscripts/run_once_script.sh -- +-- home3/user/.local/share/chezmoi/.chezmoiscripts/run_script.sh -- +-- home4/user/.local/share/chezmoi/.chezmoiscripts/.chezmoiignore -- diff --git a/internal/cmd/testdata/scripts/scriptsubdir_unix.txt b/internal/cmd/testdata/scripts/scriptsubdir_unix.txt deleted file mode 100644 index 42ab23b5935..00000000000 --- a/internal/cmd/testdata/scripts/scriptsubdir_unix.txt +++ /dev/null @@ -1,78 +0,0 @@ -[windows] skip 'UNIX only' -[!umask:022] skip - -# test that scripts in subdirectories are run in the subdirectory -chezmoi apply --force -cmpenv stdout golden/apply - -chezmoi dump -cmp stdout golden/dump - -chezmoi archive --gzip --output=archive.tar.gz -exec tar -tzf archive.tar.gz -[!openbsd] cmp stdout golden/archive -[openbsd] cmp stdout golden/archive-openbsd - --- golden/apply -- -$HOME -$HOME/dir -$HOME/anotherdir --- golden/archive -- -otherdir/script -anotherdir/ -dir/ -dir/script -otherdir/ -anotherdir/script --- golden/archive-openbsd -- -otherdir/script -anotherdir -dir -dir/script -otherdir -anotherdir/script --- golden/dump -- -{ - "anotherdir": { - "type": "dir", - "name": "anotherdir", - "perm": 493 - }, - "anotherdir/script": { - "type": "script", - "name": "anotherdir/script", - "contents": "#!/bin/sh\n\npwd\n" - }, - "dir": { - "type": "dir", - "name": "dir", - "perm": 493 - }, - "dir/script": { - "type": "script", - "name": "dir/script", - "contents": "#!/bin/sh\n\npwd\n" - }, - "otherdir": { - "type": "dir", - "name": "otherdir", - "perm": 493 - }, - "otherdir/script": { - "type": "script", - "name": "otherdir/script", - "contents": "#!/bin/sh\n\npwd\n" - } -} --- home/user/.local/share/chezmoi/anotherdir/run_after_script -- -#!/bin/sh - -pwd --- home/user/.local/share/chezmoi/dir/run_script -- -#!/bin/sh - -pwd --- home/user/.local/share/chezmoi/otherdir/run_before_script -- -#!/bin/sh - -pwd diff --git a/internal/cmd/testdata/scripts/scriptsubdir_unix.txtar b/internal/cmd/testdata/scripts/scriptsubdir_unix.txtar new file mode 100644 index 00000000000..2d34981855e --- /dev/null +++ b/internal/cmd/testdata/scripts/scriptsubdir_unix.txtar @@ -0,0 +1,81 @@ +[windows] skip 'UNIX only' +[!umask:022] skip + +# test that scripts in subdirectories are run in the subdirectory +exec chezmoi apply --force +cmpenv stdout golden/apply + +exec chezmoi dump +cmp stdout golden/dump.json + +exec chezmoi archive --gzip --output=archive.tar.gz +exec tar -tzf archive.tar.gz +[!openbsd] cmp stdout golden/archive +[openbsd] cmp stdout golden/archive-openbsd + +-- golden/apply -- +$HOME +$HOME/dir +$HOME/anotherdir +-- golden/archive -- +otherdir/script.sh +anotherdir/ +dir/ +dir/script.sh +otherdir/ +anotherdir/script.sh +-- golden/archive-openbsd -- +otherdir/script.sh +anotherdir +dir +dir/script.sh +otherdir +anotherdir/script.sh +-- golden/dump.json -- +{ + "anotherdir": { + "type": "dir", + "name": "anotherdir", + "perm": 493 + }, + "anotherdir/script.sh": { + "type": "script", + "name": "anotherdir/script.sh", + "contents": "#!/bin/sh\n\npwd\n", + "condition": "always" + }, + "dir": { + "type": "dir", + "name": "dir", + "perm": 493 + }, + "dir/script.sh": { + "type": "script", + "name": "dir/script.sh", + "contents": "#!/bin/sh\n\npwd\n", + "condition": "always" + }, + "otherdir": { + "type": "dir", + "name": "otherdir", + "perm": 493 + }, + "otherdir/script.sh": { + "type": "script", + "name": "otherdir/script.sh", + "contents": "#!/bin/sh\n\npwd\n", + "condition": "always" + } +} +-- home/user/.local/share/chezmoi/anotherdir/run_after_script.sh -- +#!/bin/sh + +pwd +-- home/user/.local/share/chezmoi/dir/run_script.sh -- +#!/bin/sh + +pwd +-- home/user/.local/share/chezmoi/otherdir/run_before_script.sh -- +#!/bin/sh + +pwd diff --git a/internal/cmd/testdata/scripts/scriptsubdir_windows.txt b/internal/cmd/testdata/scripts/scriptsubdir_windows.txtar similarity index 75% rename from internal/cmd/testdata/scripts/scriptsubdir_windows.txt rename to internal/cmd/testdata/scripts/scriptsubdir_windows.txtar index 6240c170b06..6a459fdba0c 100644 --- a/internal/cmd/testdata/scripts/scriptsubdir_windows.txt +++ b/internal/cmd/testdata/scripts/scriptsubdir_windows.txtar @@ -1,16 +1,16 @@ -[!windows] skip 'Windows only' +[unix] skip 'Windows only' unix2dos golden/apply unix2dos golden/archive # test that scripts in subdirectories are run in the subdirectory -chezmoi apply --force +exec chezmoi apply --force cmpenv stdout golden/apply -chezmoi dump -cmp stdout golden/dump +exec chezmoi dump +cmp stdout golden/dump.json -chezmoi archive --gzip --output=archive.tar.gz +exec chezmoi archive --gzip --output=archive.tar.gz exec tar -tzf archive.tar.gz cmp stdout golden/archive @@ -25,7 +25,7 @@ dir/ dir/script.cmd otherdir/ anotherdir/script.cmd --- golden/dump -- +-- golden/dump.json -- { "anotherdir": { "type": "dir", @@ -35,7 +35,8 @@ anotherdir/script.cmd "anotherdir/script.cmd": { "type": "script", "name": "anotherdir/script.cmd", - "contents": "@echo %cd%\n" + "contents": "@echo %cd%\n", + "condition": "always" }, "dir": { "type": "dir", @@ -45,7 +46,8 @@ anotherdir/script.cmd "dir/script.cmd": { "type": "script", "name": "dir/script.cmd", - "contents": "@echo %cd%\n" + "contents": "@echo %cd%\n", + "condition": "always" }, "otherdir": { "type": "dir", @@ -55,7 +57,8 @@ anotherdir/script.cmd "otherdir/script.cmd": { "type": "script", "name": "otherdir/script.cmd", - "contents": "@echo %cd%\n" + "contents": "@echo %cd%\n", + "condition": "always" } } -- home/user/.local/share/chezmoi/anotherdir/run_after_script.cmd -- diff --git a/internal/cmd/testdata/scripts/scripttempdir.txtar b/internal/cmd/testdata/scripts/scripttempdir.txtar new file mode 100644 index 00000000000..fb8299ea5ca --- /dev/null +++ b/internal/cmd/testdata/scripts/scripttempdir.txtar @@ -0,0 +1,19 @@ +[windows] skip 'UNIX only' + +# test that chezmoi apply with a scriptTempDir set creates a temporary directory and runs scripts from that directory +expandenv $CHEZMOICONFIGDIR/chezmoi.toml +exec chezmoi apply +stdout $WORK/script-tmp/.*script\.sh/run_$ +grep $WORK/script-tmp/.*/modify_ $HOME/.file +exists $WORK/script-tmp + +-- home/user/.config/chezmoi/chezmoi.toml -- +scriptTempDir = "$WORK/script-tmp" +-- home/user/.local/share/chezmoi/modify_dot_file -- +#!/bin/sh + +echo $0/modify_ +-- home/user/.local/share/chezmoi/run_script.sh -- +#!/bin/sh + +echo $0/run_ diff --git a/internal/cmd/testdata/scripts/secret.txt b/internal/cmd/testdata/scripts/secret.txt deleted file mode 100644 index 57dfd8e569a..00000000000 --- a/internal/cmd/testdata/scripts/secret.txt +++ /dev/null @@ -1,25 +0,0 @@ -[!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/internal/cmd/testdata/scripts/secret.txtar b/internal/cmd/testdata/scripts/secret.txtar new file mode 100644 index 00000000000..cacfcf2c821 --- /dev/null +++ b/internal/cmd/testdata/scripts/secret.txtar @@ -0,0 +1,36 @@ +[unix] chmod 755 bin/secret +[windows] unix2dos bin/secret.cmd + +# test secret template function +exec chezmoi execute-template '{{ secret "password" }}' +stdout ^password$ + +# test secretJSON template function +exec chezmoi execute-template '{{ (secretJSON "{\"password\":\"secret\"}").password }}' +stdout ^secret$ + +chhome home2/user + +# test secret.args +exec chezmoi execute-template '{{ secret "password" }}' +stdout '^arg password$' + +-- 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" +-- home2/user/.config/chezmoi/chezmoi.yaml -- +secret: + args: + - "arg" + command: "secret" diff --git a/internal/cmd/testdata/scripts/sourcedir.txt b/internal/cmd/testdata/scripts/sourcedir.txtar similarity index 60% rename from internal/cmd/testdata/scripts/sourcedir.txt rename to internal/cmd/testdata/scripts/sourcedir.txtar index 11424e8cfbe..ed07301d42f 100644 --- a/internal/cmd/testdata/scripts/sourcedir.txt +++ b/internal/cmd/testdata/scripts/sourcedir.txtar @@ -1,4 +1,4 @@ -chezmoi execute-template '{{ .chezmoi.sourceDir }}' +exec chezmoi execute-template '{{ .chezmoi.sourceDir }}' stdout '/tmp/user' -- home/user/.config/chezmoi/chezmoi.toml -- diff --git a/internal/cmd/testdata/scripts/sourcepath.txt b/internal/cmd/testdata/scripts/sourcepath.txt deleted file mode 100644 index c12938ff146..00000000000 --- a/internal/cmd/testdata/scripts/sourcepath.txt +++ /dev/null @@ -1,18 +0,0 @@ -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/internal/cmd/testdata/scripts/sourcepath.txtar b/internal/cmd/testdata/scripts/sourcepath.txtar new file mode 100644 index 00000000000..4fd69f1413f --- /dev/null +++ b/internal/cmd/testdata/scripts/sourcepath.txtar @@ -0,0 +1,28 @@ +# test that chezmoi source-path returns the source directory +exec chezmoi source-path +stdout ^${CHEZMOISOURCEDIR@R}$ + +# test that chezmoi source-path target returns the path to a target's source file +exec chezmoi source-path $HOME${/}.file +stdout ^${CHEZMOISOURCEDIR@R}/dot_file$ + +# test that chezmoi source-path returns an error if the target is not in the source state +! exec chezmoi source-path $HOME${/}.newfile +stderr 'not managed' + +# test that chezmoi source-path returns an error if the target is not in the destination directory +! exec chezmoi source-path $WORK${/}etc${/}passwd +stderr 'not in destination directory' + +chhome home2/user + +# test that chezmoi source-path target returns the path the target's source file when .chezmoiroot is used +exec chezmoi source-path $HOME${/}.file +stdout /home/dot_file$ + +-- home/user/.local/share/chezmoi/dot_file -- +# contents of .file +-- home2/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home2/user/.local/share/chezmoi/home/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/state.txt b/internal/cmd/testdata/scripts/state.txt deleted file mode 100644 index 4256dff4d56..00000000000 --- a/internal/cmd/testdata/scripts/state.txt +++ /dev/null @@ -1,24 +0,0 @@ -[windows] mkdir $CHEZMOICONFIGDIR # FIXME remove this - -# test that chezmoi state get returns nothing for a non-existing bucket/key -chezmoi state get --bucket=bucket --key=key -! stdout . - -# test that chezmoi state set sets a value that can be retrieved with chezmoi state get -chezmoi state set --bucket=bucket --key=key --value=value -chezmoi state get --bucket=bucket --key=key -stdout value -chezmoi state data --format=yaml -cmp stdout golden/data - -# test that chezmoi state delete deletes a value -chezmoi state delete --bucket=bucket --key=key -! stdout . -chezmoi state data --format=yaml -cmp stdout golden/data-after-delete - --- golden/data -- -bucket: - key: value --- golden/data-after-delete -- -bucket: {} diff --git a/internal/cmd/testdata/scripts/state.txtar b/internal/cmd/testdata/scripts/state.txtar new file mode 100644 index 00000000000..70fe1254191 --- /dev/null +++ b/internal/cmd/testdata/scripts/state.txtar @@ -0,0 +1,24 @@ +[windows] mkdir $CHEZMOICONFIGDIR # FIXME remove this + +# test that chezmoi state get returns nothing for a non-existing bucket/key +exec chezmoi state get --bucket=bucket --key=key +! stdout . + +# test that chezmoi state set sets a value that can be retrieved with chezmoi state get +exec chezmoi state set --bucket=bucket --key=key --value=value +exec chezmoi state get --bucket=bucket --key=key +stdout value +exec chezmoi state data --format=yaml +cmp stdout golden/data.yaml + +# test that chezmoi state delete deletes a value +exec chezmoi state delete --bucket=bucket --key=key +! stdout . +exec chezmoi state data --format=yaml +cmp stdout golden/data-after-delete.yaml + +-- golden/data-after-delete.yaml -- +bucket: {} +-- golden/data.yaml -- +bucket: + key: value diff --git a/internal/cmd/testdata/scripts/state_unix.txt b/internal/cmd/testdata/scripts/state_unix.txt deleted file mode 100644 index 6f4e485b0e7..00000000000 --- a/internal/cmd/testdata/scripts/state_unix.txt +++ /dev/null @@ -1,38 +0,0 @@ -[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 70396a619400b7f78dbb83ab8ddb76ffe0b8e31557e64bab2ca9677818a52135: -stdout runAt: - -# test that chezmoi state reset removes the persistent state -chezmoi --force state reset -! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb - -# test that the --persistent-state option sets the persistent state file -chezmoi apply --force -stdout script -chezmoi apply --force --persistent-state=$CHEZMOICONFIGDIR${/}chezmoistate2.boltdb -exists $CHEZMOICONFIGDIR${/}chezmoistate2.boltdb -stdout script -chezmoi state dump --format=yaml --persistent-state=$CHEZMOICONFIGDIR${/}chezmoistate2.boltdb -stdout 70396a619400b7f78dbb83ab8ddb76ffe0b8e31557e64bab2ca9677818a52135: -stdout runAt: - --- golden/dump -- -configState: {} -entryState: {} -scriptState: {} --- home/user/.local/share/chezmoi/run_once_script -- -#!/bin/sh - -echo script diff --git a/internal/cmd/testdata/scripts/state_unix.txtar b/internal/cmd/testdata/scripts/state_unix.txtar new file mode 100644 index 00000000000..b7130f4500a --- /dev/null +++ b/internal/cmd/testdata/scripts/state_unix.txtar @@ -0,0 +1,52 @@ +[windows] skip 'UNIX only' + +# test that the persistent state is only created on demand +exec chezmoi state dump --format=yaml +cmp stdout golden/dump.yaml +! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb + +# test that chezmoi apply updates the persistent state +exec chezmoi apply --force +exists $CHEZMOICONFIGDIR/chezmoistate.boltdb + +# test that the persistent state records that script was run +exec chezmoi state dump --format=yaml +stdout 70396a619400b7f78dbb83ab8ddb76ffe0b8e31557e64bab2ca9677818a52135: +stdout runAt: + +# test that chezmoi get-bucket gets a bucket +exec chezmoi state get-bucket --bucket=scriptState +stdout "runAt": + +# test that chezmoi delete-bucket deletes a bucket +exec chezmoi state delete-bucket --bucket=scriptState +exec chezmoi state dump --format=yaml +! stdout runAt: + +# test that chezmoi state reset removes the persistent state +exec chezmoi --force state reset +! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb + +# test that the --persistent-state option sets the persistent state file +exec chezmoi apply --force +stdout script +exec chezmoi apply --force --persistent-state=$CHEZMOICONFIGDIR${/}chezmoistate2.boltdb +exists $CHEZMOICONFIGDIR${/}chezmoistate2.boltdb +stdout script +exec chezmoi state dump --format=yaml --persistent-state=$CHEZMOICONFIGDIR${/}chezmoistate2.boltdb +stdout 70396a619400b7f78dbb83ab8ddb76ffe0b8e31557e64bab2ca9677818a52135: +stdout runAt: + +-- golden/dump.yaml -- +configState: {} +entryState: {} +gitHubKeysState: {} +gitHubLatestReleaseState: {} +gitHubReleasesState: {} +gitHubTagsState: {} +gitRepoExternalState: {} +scriptState: {} +-- home/user/.local/share/chezmoi/run_once_script.sh -- +#!/bin/sh + +echo script diff --git a/internal/cmd/testdata/scripts/state_windows.txt b/internal/cmd/testdata/scripts/state_windows.txtar similarity index 64% rename from internal/cmd/testdata/scripts/state_windows.txt rename to internal/cmd/testdata/scripts/state_windows.txtar index b295113e3ff..0f936cee10c 100644 --- a/internal/cmd/testdata/scripts/state_windows.txt +++ b/internal/cmd/testdata/scripts/state_windows.txtar @@ -1,26 +1,31 @@ -[!windows] skip 'Windows only' +[unix] skip 'Windows only' # test that the persistent state is only created on demand -chezmoi state dump --format=yaml -cmp stdout golden/dump +exec chezmoi state dump --format=yaml +cmp stdout golden/dump.yaml ! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb # test that chezmoi apply updates the persistent state -chezmoi apply --force +exec chezmoi apply --force exists $CHEZMOICONFIGDIR/chezmoistate.boltdb # test that the persistent state records that script was run -chezmoi state dump --format=yaml +exec chezmoi state dump --format=yaml stdout c22efac7af1ab6b9b5b74a852250ef7c4f78ebad7d5655bf5db3b5d9d3d2070c: stdout runAt: # test that chezmoi state reset removes the persistent state -chezmoi --force state reset +exec chezmoi --force state reset ! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb --- golden/dump -- +-- golden/dump.yaml -- configState: {} entryState: {} +gitHubKeysState: {} +gitHubLatestReleaseState: {} +gitHubReleasesState: {} +gitHubTagsState: {} +gitRepoExternalState: {} scriptState: {} -- home/user/.local/share/chezmoi/run_once_script.cmd -- :: don't need to actually do anything diff --git a/internal/cmd/testdata/scripts/status.txt b/internal/cmd/testdata/scripts/status.txt deleted file mode 100644 index 148f0912a02..00000000000 --- a/internal/cmd/testdata/scripts/status.txt +++ /dev/null @@ -1,56 +0,0 @@ -# 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 .create - A .dir - A .dir/file - A .dir/subdir - A .dir/subdir/file - A .empty - A .executable - A .file - A .private - A .readonly - A .symlink - A .template --- golden/status-except-dot-file -- - A .create - A .dir - A .dir/file - A .dir/subdir - A .dir/subdir/file - A .empty - A .executable - A .private - A .readonly - A .symlink - A .template --- golden/status-modified-file -- -MM .file diff --git a/internal/cmd/testdata/scripts/status.txtar b/internal/cmd/testdata/scripts/status.txtar new file mode 100644 index 00000000000..5cbae43d6b5 --- /dev/null +++ b/internal/cmd/testdata/scripts/status.txtar @@ -0,0 +1,99 @@ +# FIXME add more tests + +mkhomedir golden +mksourcedir + +# test that chezmoi status lists all files to be added +exec chezmoi status +cmp stdout golden/status + +# test that the --path-style=absolute works as expected +[unix] exec chezmoi status --path-style=absolute +[unix] cmpenv stdout golden/status-absolute-flag + +# test that chezmoi status lists all files to be added +exec chezmoi status +cmp stdout golden/status + +# test that chezmoi status omits applied files +exec chezmoi apply --force $HOME${/}.file +exec chezmoi status +cmp stdout golden/status-except-dot-file + +# test that chezmoi status is empty after apply +exec chezmoi apply --force +exec chezmoi status +! stdout . + +# test that chezmoi status identifies files that have been modified in the destination directory +edit $HOME/.file +exec chezmoi status +cmp stdout golden/status-modified-file + +# test that chezmoi status does not emit status for equivalent modifications +edit $CHEZMOISOURCEDIR/dot_file +exec chezmoi status +! stdout . + +# test that the pathStyle config option works as expected +[unix] chhome home2/user +[unix] mksourcedir +[unix] exec chezmoi status +[unix] cmpenv stdout golden/status-absolute-config + +-- golden/status -- + A .create + A .dir + A .dir/file + A .dir/subdir + A .dir/subdir/file + A .empty + A .executable + A .file + A .private + A .readonly + A .symlink + A .template +-- golden/status-absolute-config -- + A $WORK/home2/user/.create + A $WORK/home2/user/.dir + A $WORK/home2/user/.dir/file + A $WORK/home2/user/.dir/subdir + A $WORK/home2/user/.dir/subdir/file + A $WORK/home2/user/.empty + A $WORK/home2/user/.executable + A $WORK/home2/user/.file + A $WORK/home2/user/.private + A $WORK/home2/user/.readonly + A $WORK/home2/user/.symlink + A $WORK/home2/user/.template +-- golden/status-absolute-flag -- + A $WORK/home/user/.create + A $WORK/home/user/.dir + A $WORK/home/user/.dir/file + A $WORK/home/user/.dir/subdir + A $WORK/home/user/.dir/subdir/file + A $WORK/home/user/.empty + A $WORK/home/user/.executable + A $WORK/home/user/.file + A $WORK/home/user/.private + A $WORK/home/user/.readonly + A $WORK/home/user/.symlink + A $WORK/home/user/.template +-- golden/status-except-dot-file -- + A .create + A .dir + A .dir/file + A .dir/subdir + A .dir/subdir/file + A .empty + A .executable + A .private + A .readonly + A .symlink + A .template +-- golden/status-modified-file -- +MM .file +-- home2/user/.config/chezmoi/chezmoi.toml -- +[status] + pathStyle = "absolute" diff --git a/internal/cmd/testdata/scripts/symlinks.txt b/internal/cmd/testdata/scripts/symlinks.txtar similarity index 84% rename from internal/cmd/testdata/scripts/symlinks.txt rename to internal/cmd/testdata/scripts/symlinks.txtar index 56a1c13970b..393f6cb199e 100644 --- a/internal/cmd/testdata/scripts/symlinks.txt +++ b/internal/cmd/testdata/scripts/symlinks.txtar @@ -1,27 +1,27 @@ # test that chezmoi apply removes a symlink if the target is empty symlink $HOME/.empty -> .file -chezmoi apply $HOME${/}.empty +exec chezmoi apply $HOME${/}.empty ! exists $HOME/.empty # test that chezmoi apply evaluates symlink templates -chezmoi apply $HOME${/}.template +exec chezmoi apply $HOME${/}.template cmp $HOME/.template $HOME/.file # test that chezmoi add --template-symlinks replaces absolute symlinks, pointing to files inside home, with templates symlink $HOME/.symlink_absolute -> $HOME/.dir/subdir/file -chezmoi add --template-symlinks $HOME${/}.symlink_absolute +exec chezmoi add --template-symlinks $HOME${/}.symlink_absolute cmp $CHEZMOISOURCEDIR/symlink_dot_symlink_absolute.tmpl golden/symlink_dot_symlink_absolute.tmpl # test that chezmoi add --template-symlinks replaces absolute symlinks, pointing to files inside the source directory, with templates symlink $HOME/.symlink_source -> $CHEZMOISOURCEDIR/.dir/subdir/file -chezmoi add --template-symlinks $HOME${/}.symlink_source +exec chezmoi add --template-symlinks $HOME${/}.symlink_source cmp $CHEZMOISOURCEDIR/symlink_dot_symlink_source.tmpl golden/symlink_dot_symlink_source.tmpl chhome home2/user # test that chezmoi add reads add.templateSymlinks from the config file symlink $HOME/.symlink_absolute -> $HOME/.dir/subdir/file -chezmoi add --template-symlinks $HOME${/}.symlink_absolute +exec chezmoi add --template-symlinks $HOME${/}.symlink_absolute cmp $CHEZMOISOURCEDIR/symlink_dot_symlink_absolute.tmpl golden/symlink_dot_symlink_absolute.tmpl -- golden/symlink_dot_symlink_absolute.tmpl -- diff --git a/internal/cmd/testdata/scripts/symlinks_windows.txt b/internal/cmd/testdata/scripts/symlinks_windows.txtar similarity index 78% rename from internal/cmd/testdata/scripts/symlinks_windows.txt rename to internal/cmd/testdata/scripts/symlinks_windows.txtar index df0f4b03848..0126605091e 100644 --- a/internal/cmd/testdata/scripts/symlinks_windows.txt +++ b/internal/cmd/testdata/scripts/symlinks_windows.txtar @@ -1,12 +1,12 @@ -[!windows] skip 'Windows only' +[unix] skip 'Windows only' # test that chezmoi apply normalizes symlinks -chezmoi apply $HOME${/}.symlink_backward_slash $HOME${/}.symlink_forward_slash +exec chezmoi apply $HOME${/}.symlink_backward_slash $HOME${/}.symlink_forward_slash readlink $HOME/.symlink_backward_slash $HOME\.dir\file readlink $HOME/.symlink_forward_slash $HOME\.dir\file # test that the persistent state matches the actual state -chezmoi verify +exec chezmoi verify -- home/user/.dir/file -- # contents of .dir/file diff --git a/internal/cmd/testdata/scripts/targetpath.txtar b/internal/cmd/testdata/scripts/targetpath.txtar new file mode 100644 index 00000000000..80c9fc93814 --- /dev/null +++ b/internal/cmd/testdata/scripts/targetpath.txtar @@ -0,0 +1,41 @@ +[windows] skip 'test requires path separator to be forward slash' +mksourcedir + +# test that chezmoi target-path without arguments prints the target directory +exec chezmoi target-path +stdout ^${HOME@R}$ + +# test that chezmoi target-path prints the target path of a directory +exec chezmoi target-path $CHEZMOISOURCEDIR/dot_dir/exact_subdir +stdout ^${HOME@R}/.dir/subdir$ + +# test that chezmoi target-path prints the target path of a file +exec chezmoi target-path $CHEZMOISOURCEDIR/dot_dir/exact_subdir/file +stdout ^${HOME@R}/.dir/subdir/file$ + +# test that chezmoi target-path prints the target path of a script +exec chezmoi target-path $CHEZMOISOURCEDIR/run_script +stdout ^${HOME@R}/script$ + +# test that chezmoi target-path prints the target path of a symlink +exec chezmoi target-path $CHEZMOISOURCEDIR/symlink_dot_symlink +stdout ^${HOME@R}/.symlink$ + +chhome home2/user + +# test that chezmoi target-path respects .chezmoiroot +exec chezmoi target-path $CHEZMOISOURCEDIR/home/dot_file +stdout ^${HOME@R}/\.file$ + +# test that chezmoi target-path respects .chezmoiscripts in .chezmoiroot +exec chezmoi target-path $CHEZMOISOURCEDIR/home/.chezmoiscripts/run_script.sh +stdout ^${HOME@R}/\.chezmoiscripts/script\.sh$ + +-- home/user/.local/share/chezmoi/run_script -- +#!/bin/sh +-- home2/user/.local/share/chezmoi/.chezmoiroot -- +home +-- home2/user/.local/share/chezmoi/home/.chezmoiscripts/run_script.sh -- +#!/bin/sh +-- home2/user/.local/share/chezmoi/home/dot_file -- +# contents of .file diff --git a/internal/cmd/testdata/scripts/templatedata.txt b/internal/cmd/testdata/scripts/templatedata.txt deleted file mode 100644 index 438ead6c709..00000000000 --- a/internal/cmd/testdata/scripts/templatedata.txt +++ /dev/null @@ -1,31 +0,0 @@ -# test that .chezmoi.sourceDir can be used with joinPath -[!windows] chezmoi execute-template '{{ joinPath .chezmoi.sourceDir ".file" }}' -[!windows] stdout ${CHEZMOISOURCEDIR@R}/.file - -# test that .chezmoi.sourceFile is set -chezmoi cat $HOME${/}.file -stdout dot_file.tmpl - -# test that .chezmoi.kernel is set on linux -[linux] chezmoi execute-template '{{ .chezmoi.kernel.ostype }}' -[linux] stdout Linux - -chhome home2/user - -# test that .chezmoidata. and .chezmoitemplates are available in .chezmoiignore -chezmoi apply -exists $HOME/.file1 -! exists $HOME/.file2 - --- home/user/.local/share/chezmoi/dot_file.tmpl -- -{{ .chezmoi.sourceFile }} --- home2/user/.local/share/chezmoi/.chezmoidata.toml -- -filename = ".file2" --- home2/user/.local/share/chezmoi/.chezmoitemplates/ignore -- -{{ .filename }} --- home2/user/.local/share/chezmoi/.chezmoiignore -- -{{ template "ignore" . }} --- home2/user/.local/share/chezmoi/dot_file1 -- -# contents of .file1 --- home2/user/.local/share/chezmoi/dot_file2 -- -# contents of .file2 diff --git a/internal/cmd/testdata/scripts/templatedata.txtar b/internal/cmd/testdata/scripts/templatedata.txtar new file mode 100644 index 00000000000..d07b9f90378 --- /dev/null +++ b/internal/cmd/testdata/scripts/templatedata.txtar @@ -0,0 +1,65 @@ +# test that .chezmoi.sourceDir can be used with joinPath +[unix] exec chezmoi execute-template '{{ joinPath .chezmoi.sourceDir ".file" }}' +[unix] stdout ${CHEZMOISOURCEDIR@R}/.file + +# test that .chezmoi.sourceFile is set +exec chezmoi cat $HOME${/}.file +stdout dot_file.tmpl + +# test that .chezmoi.kernel is set on linux +[linux] exec chezmoi execute-template '{{ .chezmoi.kernel.ostype }}' +[linux] stdout Linux + +chhome home2/user + +# test that .chezmoidata. and .chezmoitemplates are available in .chezmoiignore +exec chezmoi apply +exists $HOME/.file1 +! exists $HOME/.file2 + +chhome home3/user + +# test that data execute-template ignores template errors +exec chezmoi data +stdout ok + +# test that chezmoi execute-template ignores template errors +exec chezmoi execute-template '{{ template "template" . }}' +stdout ok + +[unix] chhome home4/user + +# test that .chezmoi.sourceFile is set +[unix] exec chezmoi cat $HOME${/}.file +[unix] cmpenv stdout golden/dot_file + +-- golden/dot_file -- +dot_file.tmpl +$WORK/home4/user/.file +-- home/user/.local/share/chezmoi/dot_file.tmpl -- +{{ .chezmoi.sourceFile }} +-- home2/user/.local/share/chezmoi/.chezmoidata.toml -- +filename = ".file2" +-- home2/user/.local/share/chezmoi/.chezmoiignore -- +{{ template "ignore" . }} +-- home2/user/.local/share/chezmoi/.chezmoitemplates/ignore -- +{{ .filename }} +-- home2/user/.local/share/chezmoi/dot_file1 -- +# contents of .file1 +-- home2/user/.local/share/chezmoi/dot_file2 -- +# contents of .file2 +-- home3/user/.local/share/chezmoi/.chezmoi.toml.tmpl -- +{{ "invalid template" +-- home3/user/.local/share/chezmoi/.chezmoidata.yaml -- +message: ok +-- home3/user/.local/share/chezmoi/.chezmoiexternal.toml -- +{{ "invalid template" +-- home3/user/.local/share/chezmoi/.chezmoiignore -- +{{ "invalid template" +-- home3/user/.local/share/chezmoi/.chezmoiremove -- +{{ "invalid template" +-- home3/user/.local/share/chezmoi/.chezmoitemplates/template -- +{{ .message }} +-- home4/user/.local/share/chezmoi/dot_file.tmpl -- +{{ .chezmoi.sourceFile }} +{{ .chezmoi.targetFile }} diff --git a/internal/cmd/testdata/scripts/templatedirectives.txtar b/internal/cmd/testdata/scripts/templatedirectives.txtar new file mode 100644 index 00000000000..63d1f558a08 --- /dev/null +++ b/internal/cmd/testdata/scripts/templatedirectives.txtar @@ -0,0 +1,17 @@ +# test --left-delimiter and --right-delimiter flags to chezmoi execute-template +exec chezmoi execute-template --left-delimiter=[[ --right-delimiter=]] '[[ "ok" ]]' +stdout ^ok$ + +# test that template delimiters can be set in files +exec chezmoi cat $HOME${/}template +cmp stdout golden/template + +-- golden/template -- +(nested) +-- home/user/.local/share/chezmoi/.chezmoitemplates/nested -- +# chezmoi:template:left-delimiter=(( right-delimiter=)) +((- . -)) +-- home/user/.local/share/chezmoi/template.tmpl -- +# chezmoi:template:left-delimiter=[[ right-delimiter=]] +# chezmoi:template:missing-key=default +[[ .MissingKey ]]([[ template "nested" "nested" ]]) diff --git a/internal/cmd/testdata/scripts/templatefuncs.txt b/internal/cmd/testdata/scripts/templatefuncs.txt deleted file mode 100644 index ae1cfa2d8d4..00000000000 --- a/internal/cmd/testdata/scripts/templatefuncs.txt +++ /dev/null @@ -1,136 +0,0 @@ -[!windows] chmod 755 bin/chezmoi-output-test -[!windows] chmod 755 bin/generate-color-formats -[!windows] chmod 755 bin/ioreg -[windows] unix2dos bin/chezmoi-output-test.cmd - -# test ioreg template function -[darwin] chezmoi execute-template '{{ index ioreg "IOKitBuildVersion" }}' -[darwin] stdout 'Darwin Kernel Version' - -# test include template function with absolute path -# -# this test is disabled on Windows because the backslashes in Windows paths are -# interpreted as a escape characters in the string constant, which breaks the -# test -# -# FIXME fix this test on Windows -[!windows] exec echo {{ "$HOME/.include" }} -[!windows] stdin stdout -[!windows] chezmoi execute-template -[!windows] cmpenv stdout golden/include-abspath - -# test include template function with relative paths -chezmoi execute-template '{{ include ".include" }}' -cmp stdout golden/include-relpath - -# test joinPath template function -chezmoi execute-template '{{ joinPath "a" "b" }}' -stdout a${/}b - -# test lookPath template function to find in PATH -chezmoi execute-template '{{ lookPath "go" }}' -stdout go$exe - -# test lookPath template function to check if file exists -chezmoi execute-template '{{ lookPath "/non-existing-file" }}' -! stdout . - -# test mozillaInstallHash template function -chezmoi execute-template '{{ mozillaInstallHash "/Applications/Firefox.app/Contents/MacOS" }}' -stdout 2656FF1E876E9973 - -# test the output and fromJson template functions -[!windows] chezmoi execute-template '{{ $red := output "generate-color-formats" "#ff0000" | fromJson }}{{ $red.rgb.r }}' -[!windows] stdout '^255$' - -# test that the output function returns an error if the command fails -[!windows] ! chezmoi execute-template '{{ output "false" }}' -[!(windows||illumos)] stderr 'error calling output: exit status 1' -[illumos] stderr 'error calling output: exit status 255' - -# test stat template function -chezmoi execute-template '{{ (stat ".").isDir }}' -stdout true - -# test that the output template function returns a command's output -chezmoi execute-template '{{ output "chezmoi-output-test" "arg" | trim }}' -stdout arg - -# test that the output template function fails if the command fails -! chezmoi execute-template '{{ output "false" }}' - -# test fromYaml -chezmoi execute-template '{{ (fromYaml "key1: value1\nkey2: value2").key2 }}' -stdout '^value2$' - -# test toYaml -chezmoi execute-template '{{ dict "key" "value" | toYaml }}' -stdout '^key: value$' - -# test writeToStdout -chezmoi execute-template --init '{{ writeToStdout "string" }}' -stdout string - --- bin/chezmoi-output-test -- -#!/bin/sh - -echo "$*" --- bin/chezmoi-output-test.cmd -- -@echo off -setlocal -set out=%* -set out=%out:\=% -echo %out% -endlocal --- bin/generate-color-formats -- -#!/bin/sh - -case "$1" in -"#ff0000") - cat <" - ;; -esac --- bin/ioreg -- -#!/bin/sh - -echo '' -echo '' -echo '' -echo '' -echo ' IOKitBuildVersion' -echo ' Darwin Kernel Version 21.1.0: Wed Oct 13 17:33:24 PDT 2021; root:xnu-8019.41.5~1/RELEASE_ARM64_T8101' -echo '' -echo '' --- golden/expected -- -255 --- golden/include-abspath -- -$HOME/.include --- golden/include-relpath -- -# contents of .local/share/chezmoi/.include --- home/user/.include -- -# contents of $HOME/.include --- home/user/.local/share/chezmoi/.include -- -# contents of .local/share/chezmoi/.include diff --git a/internal/cmd/testdata/scripts/templatefuncs.txtar b/internal/cmd/testdata/scripts/templatefuncs.txtar new file mode 100644 index 00000000000..bbfd45d00b5 --- /dev/null +++ b/internal/cmd/testdata/scripts/templatefuncs.txtar @@ -0,0 +1,300 @@ +[unix] chmod 755 bin/chezmoi-output-test +[unix] chmod 755 bin/generate-color-formats +[unix] chmod 755 bin/ioreg +[unix] chmod 755 bin/executable +[windows] unix2dos bin/chezmoi-output-test.cmd + +symlink $HOME/symlink -> dir + +# test comment template function +exec chezmoi execute-template '{{ "line1\nline2" | comment "# " }}' +rmfinalnewline golden/comment +cmp stdout golden/comment + +# test completion template function +exec chezmoi execute-template '{{ completion "zsh" }}' +stdout '^# zsh completion for chezmoi' + +# test deleteValueAtPath template function +exec chezmoi execute-template '{{ dict "a" (dict "b" (dict "c" 1 "d" 2)) | deleteValueAtPath "a.b.c" | toJson }}' +rmfinalnewline golden/deleteValueAtPath +cmp stdout golden/deleteValueAtPath + +# test eqFold template function +exec chezmoi execute-template '{{ eqFold "foo" "Foo" "FOO" }}' +stdout '^true$' + +# test that the fromJson template function can deserialize JSON values +exec chezmoi execute-template '{{ fromJson "1" }}' +stdout '^1$' + +# test that the fromJson template function can deserialize JSON arrays +exec chezmoi execute-template '{{ fromJson "[1, 2]" }}' +stdout '^\[1 2\]$' + +# test fromJsonc template function +stdin golden/example.jsonc +exec chezmoi execute-template --with-stdin '{{ fromJsonc .chezmoi.stdin | toJson }}' +stdout '{"key":1}' + +# test glob template function +exec chezmoi execute-template '{{ glob "*.txt" | join "\n" }}{{ "\n" }}' +cmp stdout golden/glob + +# test hexDecode template function +exec chezmoi execute-template '{{ "6578616d706c65" | hexDecode }}' +stdout '^example$' + +# test hexEncode template function +exec chezmoi execute-template '{{ "example" | hexEncode }}' +stdout '^6578616d706c65$' + +# test ioreg template function +[darwin] exec chezmoi execute-template '{{ index ioreg "IOKitBuildVersion" }}' +[darwin] stdout 'Darwin Kernel Version' + +# test include template function with absolute path +exec chezmoi execute-template '{{ joinPath (env "HOME") ".include" | include }}' +cmp stdout golden/include-abspath + +# test include template function with relative paths +exec chezmoi execute-template '{{ include ".include" }}' +cmp stdout golden/include-relpath + +# test includeTemplate template function +exec chezmoi execute-template '{{ includeTemplate ".template" "data" }}' +stdout ^data$ + +# test includeTemplate template function searches .chezmoitemplates +exec chezmoi execute-template '{{ includeTemplate "template" "data" }}' +stdout ^data$ + +# test joinPath template function +exec chezmoi execute-template '{{ joinPath "a" "b" }}' +stdout a${/}b + +# test jq template function +exec chezmoi execute-template '{{ dict "key" "value" | jq ".key" | first }}' +stdout ^value$ + +# test isExecutable template function positive test case +[unix] exec chezmoi execute-template '{{ isExecutable "bin/executable" }}' +[windows] exec chezmoi execute-template '{{ isExecutable "bin/executable.cmd" }}' +stdout ^true$ + +# test isExecutable template function negative test case +exec chezmoi execute-template '{{ isExecutable "bin/not-executable" }}' +stdout ^false$ + +# test findExecutable template function to find in specified script varargs - success +[!windows] exec chezmoi execute-template '{{ findExecutable "echo" (list "/lib" "/bin" "/usr/bin") }}' +[!windows] stdout ^/bin/echo$ + +# test findOneExecutable template function to find in specified script varargs - success +[!windows] exec chezmoi execute-template '{{ findOneExecutable (list "chezmoish" "echo") (list "/lib" "/bin" "/usr/bin") }}' +[!windows] stdout ^/bin/echo$ + +# test findExecutable template function to find in specified script varargs - failure +[!windows] exec chezmoi execute-template '{{ findExecutable "echo" (list "/lib") }}' +[!windows] stdout ^$ + +# test findExecutable template function to find in specified script - success +[!windows] exec chezmoi execute-template '{{ findExecutable "echo" (list "/lib" "/bin" "/usr/bin") }}' +[!windows] stdout ^/bin/echo$ + +# test findExecutable template function to find in specified script - failure +[!windows] exec chezmoi execute-template '{{ findExecutable "echo" (list "/lib") }}' +[!windows] stdout ^$ + +# test findExecutable template function to find in specified script - success with extension +[windows] exec chezmoi execute-template '{{ findExecutable "git.exe" (list "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd") }}' +[windows] stdout 'git' + +# test findExecutable template function to find in specified script - success without extension +[windows] exec chezmoi execute-template '{{ findExecutable "git" (list "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd") }}' +[windows] stdout 'git' + +# test findExecutable template function to find in specified script - failure +[windows] exec chezmoi execute-template '{{ findExecutable "asdf" (list "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd") }}' +[windows] stdout '^$' + +# test lookPath template function to find in PATH +exec chezmoi execute-template '{{ lookPath "go" }}' +stdout go$exe + +# test lookPath template function to check if file exists +exec chezmoi execute-template '{{ lookPath "/non-existing-file" }}' +! stdout . + +# test lstat template function +exec chezmoi execute-template '{{ (joinPath .chezmoi.homeDir "symlink" | lstat).type }}' +stdout ^symlink$ + +# test mozillaInstallHash template function +exec chezmoi execute-template '{{ mozillaInstallHash "/Applications/Firefox.app/Contents/MacOS" }}' +stdout 2656FF1E876E9973 + +# test the output and fromJson template functions +[unix] exec chezmoi execute-template '{{ $red := output "generate-color-formats" "#ff0000" | fromJson }}{{ $red.rgb.r }}' +[unix] stdout '^255$' + +# test that the output function returns an error if the command fails +[unix] ! exec chezmoi execute-template '{{ output "false" }}' +[unix] stderr 'error calling output: .*/false: exit status 1' + +# test pruneEmptyDicts template function +exec chezmoi execute-template '{{ dict "key1" "value1" "key2" (dict) | pruneEmptyDicts | toJson }}' +rmfinalnewline golden/pruneEmptyDicts +cmp stdout golden/pruneEmptyDicts + +# test replaceAllRegex template function +exec chezmoi execute-template '{{ "foo bar baz" | replaceAllRegex "ba" "BA" }}' +stdout 'foo BAr BAz' + +# test setValueAtPath template function +exec chezmoi execute-template '{{ dict | setValueAtPath "key1.key2" "value2" | toJson }}' +rmfinalnewline golden/setValueAtPath +cmp stdout golden/setValueAtPath + +# test toIni template function +exec chezmoi execute-template '{{ dict "key" "value" "section" (dict "subkey" "subvalue") | toIni }}' +cmp stdout golden/toIni + +# test stat template function +exec chezmoi execute-template '{{ (joinPath .chezmoi.homeDir "symlink" | stat).isDir }}' +stdout true + +# test that the output template function returns a command's output +exec chezmoi execute-template '{{ output "chezmoi-output-test" "arg" | trim }}' +stdout arg + +# test that the output template function fails if the command fails +! exec chezmoi execute-template '{{ output "false" }}' + +# test fromToml template function +exec chezmoi execute-template '{{ (fromToml "[section]\nkey = \"value\"").section.key }}' +stdout '^value$' + +# test toToml template function +exec chezmoi execute-template '{{ dict "key" "value" | toToml }}' +stdout '^key = .value.$' + +# test that the toPrettyJson template function does not escape HTML characters, see https://github.com/golang/go/blob/7a6ddbb425218b2f4866478d0c673ba82c8438e6/src/encoding/json/encode.go#L48-L55 +exec chezmoi execute-template '{{ dict "a" (dict "b" "&") | toPrettyJson " " }}' +cmp stdout golden/toPrettyJson + +# test fromYaml template function +exec chezmoi execute-template '{{ (fromYaml "key1: value1\nkey2: value2").key2 }}' +stdout '^value2$' + +# test toYaml template function +exec chezmoi execute-template '{{ dict "key" "value" | toYaml }}' +stdout '^key: value$' + +# test that the overridden splitList function's output is compatible with quoteList +exec chezmoi execute-template '{{ "a b" | splitList " " | quoteList }}' +stdout '["a" "b"]' + +-- bin/chezmoi-output-test -- +#!/bin/sh + +echo "$*" +-- bin/chezmoi-output-test.cmd -- +@echo off +setlocal +set out=%* +set out=%out:\=% +echo %out% +endlocal +-- bin/executable -- +#!/bin/sh +-- bin/executable.cmd -- +-- bin/generate-color-formats -- +#!/bin/sh + +case "$1" in +"#ff0000") + cat <" + ;; +esac +-- bin/ioreg -- +#!/bin/sh + +echo '' +echo '' +echo '' +echo '' +echo ' IOKitBuildVersion' +echo ' Darwin Kernel Version 21.1.0: Wed Oct 13 17:33:24 PDT 2021; root:xnu-8019.41.5~1/RELEASE_ARM64_T8101' +echo '' +echo '' +-- bin/not-executable -- +-- golden/comment -- +# line1 +# line2 +-- golden/deleteValueAtPath -- +{"a":{"b":{"d":2}}} +-- golden/example.jsonc -- +{ + "key": 1, // Comment +} +-- golden/expected -- +255 +-- golden/glob -- +file1.txt +file2.txt +-- golden/include-abspath -- +# contents of .include +-- golden/include-relpath -- +# contents of .local/share/chezmoi/.include +-- golden/pruneEmptyDicts -- +{"key1":"value1"} +-- golden/setValueAtPath -- +{"key1":{"key2":"value2"}} +-- golden/toIni -- +key = value + +[section] +subkey = subvalue +-- golden/toPrettyJson -- +{ + "a": { + "b": "&" + } +} +-- home/user/.include -- +# contents of .include +-- home/user/.local/share/chezmoi/.chezmoitemplates/template -- +{{ . }} +-- home/user/.local/share/chezmoi/.include -- +# contents of .local/share/chezmoi/.include +-- home/user/.local/share/chezmoi/.template -- +chezmoi:template:left-delimiter=[[ right-delimiter=]] +[[ . ]] +-- home/user/.local/share/chezmoi/template -- +-- home/user/dir/.keep -- +-- home/user/file1.txt -- +-- home/user/file2.txt -- diff --git a/internal/cmd/testdata/scripts/templatevars.txt b/internal/cmd/testdata/scripts/templatevars.txt deleted file mode 100644 index 4b2effa22fe..00000000000 --- a/internal/cmd/testdata/scripts/templatevars.txt +++ /dev/null @@ -1,28 +0,0 @@ -chezmoi execute-template '{{ .chezmoi.arch }}' -[386] stdout 386 -[amd64] stdout amd64 -[arm] stdout arm -[arm64] stdout arm64 -[ppc64] stdout ppc64 -[ppc64le] stdout ppc64le - -chezmoi execute-template '{{ index .chezmoi.args 1 }}' -stdout execute-template - -chezmoi execute-template '{{ .chezmoi.executable }}' -stdout [\\/]chezmoi(.exe)?$ - -chezmoi execute-template '{{ .chezmoi.homeDir }}' -stdout ${HOME@R} - -chezmoi execute-template '{{ .chezmoi.os }}' -[darwin] stdout darwin -[freebsd] stdout freebsd -[illumos] stdout illumos -[linux] stdout linux -[openbsd] stdout openbsd -[solaris] stdout solaris -[windows] stdout windows - -chezmoi execute-template '{{ .chezmoi.sourceDir }}' -stdout ${CHEZMOISOURCEDIR@R} diff --git a/internal/cmd/testdata/scripts/templatevars.txtar b/internal/cmd/testdata/scripts/templatevars.txtar new file mode 100644 index 00000000000..5851b06f9c7 --- /dev/null +++ b/internal/cmd/testdata/scripts/templatevars.txtar @@ -0,0 +1,26 @@ +exec chezmoi execute-template '{{ .chezmoi.arch }}' +[386] stdout 386 +[amd64] stdout amd64 +[arm] stdout arm +[arm64] stdout arm64 +[ppc64] stdout ppc64 +[ppc64le] stdout ppc64le + +exec chezmoi execute-template '{{ index .chezmoi.args 1 }}' +stdout execute-template + +exec chezmoi execute-template '{{ .chezmoi.executable }}' +stdout [\\/]chezmoi(.exe)?$ + +[unix] exec chezmoi execute-template '{{ .chezmoi.homeDir }}' +[unix] stdout ${HOME@R} + +exec chezmoi execute-template '{{ .chezmoi.os }}' +[darwin] stdout darwin +[freebsd] stdout freebsd +[linux] stdout linux +[openbsd] stdout openbsd +[windows] stdout windows + +exec chezmoi execute-template '{{ .chezmoi.sourceDir }}' +stdout ${CHEZMOISOURCEDIR@R} diff --git a/internal/cmd/testdata/scripts/textconv.txtar b/internal/cmd/testdata/scripts/textconv.txtar new file mode 100644 index 00000000000..32b28213ffe --- /dev/null +++ b/internal/cmd/testdata/scripts/textconv.txtar @@ -0,0 +1,53 @@ +[windows] skip 'UNIX only' +[!exec:tr] skip 'tr not found in $PATH' + +# test that chezmoi diff uses textconv +exec chezmoi diff +[umask:002] cmp stdout golden/diff-umask-002 +[umask:022] cmp stdout golden/diff-umask-022 + +# test that chezmoi apply uses textconv +exec chezmoi apply --verbose +[umask:002] cmp stdout golden/diff-umask-002 +[umask:022] cmp stdout golden/diff-umask-022 +cmp $HOME/file.txt golden/file.txt + +# test that chezmoi apply uses textconv in interactive diffs +edit $HOME/file.txt +stdin golden/diff-overwrite +exec chezmoi apply --no-tty +stdout '^ # CONTENTS OF FILE.TXT' +stdout '^-# EDITED' + +-- golden/diff-overwrite -- +diff +overwrite +-- golden/diff-umask-002 -- +diff --git a/file.txt b/file.txt +index b0ebb2af412bf3812b0bf8c5d7b950feb8a701be..4d977287915d918f15ef3df13146c1fa58914d30 100664 +--- a/file.txt ++++ b/file.txt +@@ -1 +1 @@ +-# PREVIOUS CONTENTS OF FILE.TXT ++# CONTENTS OF FILE.TXT +-- golden/diff-umask-022 -- +diff --git a/file.txt b/file.txt +index b0ebb2af412bf3812b0bf8c5d7b950feb8a701be..4d977287915d918f15ef3df13146c1fa58914d30 100644 +--- a/file.txt ++++ b/file.txt +@@ -1 +1 @@ +-# PREVIOUS CONTENTS OF FILE.TXT ++# CONTENTS OF FILE.TXT +-- golden/file.txt -- +# contents of file.txt +-- home/user/.config/chezmoi/chezmoi.yaml -- +textconv: +- pattern: '**/*.txt' + command: tr + args: + - a-z + - A-Z +-- home/user/.local/share/chezmoi/file.txt -- +# contents of file.txt +-- home/user/file.txt -- +# previous contents of file.txt diff --git a/internal/cmd/testdata/scripts/tilde.txt b/internal/cmd/testdata/scripts/tilde.txt deleted file mode 100644 index 5eddc179f7c..00000000000 --- a/internal/cmd/testdata/scripts/tilde.txt +++ /dev/null @@ -1,8 +0,0 @@ -mkhomedir -mksourcedir - -chezmoi source-path ~/.file -stdout $CHEZMOISOURCEDIR/dot_file - -! chezmoi source-path ~ -stderr 'not in' diff --git a/internal/cmd/testdata/scripts/tilde.txtar b/internal/cmd/testdata/scripts/tilde.txtar new file mode 100644 index 00000000000..458c9f4e3f5 --- /dev/null +++ b/internal/cmd/testdata/scripts/tilde.txtar @@ -0,0 +1,8 @@ +mkhomedir +mksourcedir + +exec chezmoi source-path ~/.file +stdout $CHEZMOISOURCEDIR/dot_file + +! exec chezmoi source-path ~ +stderr 'not managed' diff --git a/internal/cmd/testdata/scripts/umask_unix.txt b/internal/cmd/testdata/scripts/umask_unix.txtar similarity index 60% rename from internal/cmd/testdata/scripts/umask_unix.txt rename to internal/cmd/testdata/scripts/umask_unix.txtar index 69e7ebbe27d..eb4a8ffb9b5 100644 --- a/internal/cmd/testdata/scripts/umask_unix.txt +++ b/internal/cmd/testdata/scripts/umask_unix.txtar @@ -4,46 +4,46 @@ chmod 777 $HOME/.file # test that chezmoi diff prints a diff when a file's permissions are different -chezmoi diff +exec chezmoi diff cmp stdout golden/diff # test that chezmoi verify fails when a file's permissions are different -! chezmoi verify +! exec chezmoi verify # test that chezmoi apply updates file permissions -chezmoi apply --force -chezmoi diff +exec chezmoi apply --force +exec chezmoi diff ! stdout . -chezmoi verify +exec chezmoi verify # test that chezmoi ignores incorrect permissions in the persistent state if the actual file permissions match and updates the persistent state -chezmoi state get --bucket=entryState --key=$HOME/.file -cmp stdout golden/state-file-get -chezmoi state set --bucket=entryState --key=$HOME/.file --value={"type":"file","mode":436,"contentsSHA256":"634a4dd193c7b3b926d2e08026aa81a416fd41cec52854863b974af422495663"} -chezmoi apply --verbose +exec chezmoi state get --bucket=entryState --key=$HOME/.file +cmp stdout golden/state-file-get.json +exec chezmoi state set --bucket=entryState --key=$HOME/.file --value={"type":"file","mode":436,"contentsSHA256":"634a4dd193c7b3b926d2e08026aa81a416fd41cec52854863b974af422495663"} +exec chezmoi apply --verbose ! stdout . -chezmoi state get --bucket=entryState --key=$HOME/.file -cmp stdout golden/state-file-get +exec chezmoi state get --bucket=entryState --key=$HOME/.file +cmp stdout golden/state-file-get.json # test that chezmoi ignores incorrect permissions in the persistent state if the actual dir permissions match and updates the persistent state # chezmoi state get --bucket=entryState --key=$HOME/.dir # FIXME understand why un-commenting this and the next line causes the test to fail # cmp stdout golden/state-dir-get -chezmoi state set --bucket=entryState --key=$HOME/.dir --value={"type":"dir","mode":509} -chezmoi apply --verbose +exec chezmoi state set --bucket=entryState --key=$HOME/.dir --value={"type":"dir","mode":509} +exec chezmoi apply --verbose ! stdout . -chezmoi state get --bucket=entryState --key=$HOME/.dir -cmp stdout golden/state-dir-get +exec chezmoi state get --bucket=entryState --key=$HOME/.dir +cmp stdout golden/state-dir-get.json -- golden/diff -- diff --git a/.file b/.file old mode 100777 new mode 100644 --- golden/state-dir-get -- +-- golden/state-dir-get.json -- { "type": "dir", "mode": 2147484141 } --- golden/state-file-get -- +-- golden/state-file-get.json -- { "type": "file", "mode": 420, diff --git a/internal/cmd/testdata/scripts/unmanaged.txt b/internal/cmd/testdata/scripts/unmanaged.txt deleted file mode 100644 index dde48b07f1f..00000000000 --- a/internal/cmd/testdata/scripts/unmanaged.txt +++ /dev/null @@ -1,23 +0,0 @@ -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/internal/cmd/testdata/scripts/unmanaged.txtar b/internal/cmd/testdata/scripts/unmanaged.txtar new file mode 100644 index 00000000000..2df888e01c5 --- /dev/null +++ b/internal/cmd/testdata/scripts/unmanaged.txtar @@ -0,0 +1,53 @@ +mkhomedir +mksourcedir + +exec chezmoi unmanaged +cmp stdout golden/unmanaged + +rm $CHEZMOISOURCEDIR/dot_dir +exec chezmoi unmanaged +cmp stdout golden/unmanaged-dir + +rm $CHEZMOISOURCEDIR/dot_file +exec chezmoi unmanaged +cmp stdout golden/unmanaged-dir-file + +# test chezmoi unmanaged with arguments +exec chezmoi unmanaged $HOME${/}.dir $HOME${/}.file +cmp stdout golden/unmanaged-with-args + +# test chezmoi unmanaged, with child of unmanaged dir as argument +exec chezmoi unmanaged $HOME${/}.dir/subdir +cmp stdout golden/unmanaged-inside-unmanaged + +# test chezmoi unmanaged with managed arguments +exec chezmoi unmanaged $HOME${/}.create $HOME${/}.file +cmp stdout golden/unmanaged-with-some-managed + +# test that chezmoi unmanaged with absent paths should fail +! exec chezmoi unmanaged $HOME${/}absent-path + +# test chezmoi unmanaged --path-style=absolute +[unix] exec chezmoi unmanaged --path-style=absolute +[unix] cmpenv stdout golden/unmanaged-absolute + +-- golden/unmanaged -- +.local +-- golden/unmanaged-absolute -- +$WORK/home/user/.dir +$WORK/home/user/.file +$WORK/home/user/.local +-- golden/unmanaged-dir -- +.dir +.local +-- golden/unmanaged-dir-file -- +.dir +.file +.local +-- golden/unmanaged-inside-unmanaged -- +.dir/subdir +-- golden/unmanaged-with-args -- +.dir +.file +-- golden/unmanaged-with-some-managed -- +.file diff --git a/internal/cmd/testdata/scripts/unmanagedtree.txtar b/internal/cmd/testdata/scripts/unmanagedtree.txtar new file mode 100644 index 00000000000..b678de8a62b --- /dev/null +++ b/internal/cmd/testdata/scripts/unmanagedtree.txtar @@ -0,0 +1,12 @@ +# test that chezmoi unmanaged --tree produces tree-like output +exec chezmoi unmanaged --tree +cmp stdout golden/stdout + +-- golden/stdout -- +.dir + file + subdir +.local +-- home/user/.dir/file -- +-- home/user/.dir/subdir/file -- +-- home/user/.local/share/chezmoi/dot_dir/.keep -- diff --git a/internal/cmd/testdata/scripts/update.txt b/internal/cmd/testdata/scripts/update.txtar similarity index 56% rename from internal/cmd/testdata/scripts/update.txt rename to internal/cmd/testdata/scripts/update.txtar index 39adc5bc77a..f4ba138d15c 100644 --- a/internal/cmd/testdata/scripts/update.txt +++ b/internal/cmd/testdata/scripts/update.txtar @@ -6,55 +6,55 @@ mkhomedir exec git init --bare $WORK/dotfiles.git -chezmoi init file://$WORK/dotfiles.git +exec 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 +exec chezmoi add $HOME${/}.file +exec chezmoi git add dot_file +exec chezmoi git commit -- --message 'Add dot_file' +exec chezmoi git push chhome home2/user # test that chezmoi init --apply inits and applies mkgitconfig -chezmoi init --apply --force file://$WORK/dotfiles.git +exec chezmoi init --apply --force file://$WORK/dotfiles.git cmp $HOME/.file golden/.file chhome home/user # create and push a new commit edit $CHEZMOISOURCEDIR/dot_file -chezmoi git -- add dot_file -chezmoi git -- commit -m 'Update dot_file' -chezmoi git -- push +exec chezmoi git -- add dot_file +exec chezmoi git -- commit -m 'Update dot_file' +exec chezmoi git -- push chhome home2/user # test chezmoi update -chezmoi update +exec chezmoi update grep -count=1 '# edited' $HOME/.file chhome home/user # create and push a new commit edit $CHEZMOISOURCEDIR/dot_file -chezmoi git -- add dot_file -chezmoi git -- commit -m 'Update dot_file' -chezmoi git -- push +exec chezmoi git -- add dot_file +exec chezmoi git -- commit -m 'Update dot_file' +exec chezmoi git -- push chhome home2/user # test chezmoi update --apply=false -chezmoi update --apply=false +exec chezmoi update --apply=false grep -count=1 '# edited' $HOME/.file -chezmoi apply --force +exec chezmoi apply --force grep -count=2 '# edited' $HOME/.file # test chezmoi update --init cp golden/.chezmoi.toml.tmpl $CHEZMOISOURCEDIR -chezmoi update --init -chezmoi execute-template '{{ .key }}' +exec chezmoi update --init +exec chezmoi execute-template '{{ .key }}' stdout value -- golden/.chezmoi.toml.tmpl -- diff --git a/internal/cmd/testdata/scripts/upgrade.txtar b/internal/cmd/testdata/scripts/upgrade.txtar new file mode 100644 index 00000000000..924cf56fd34 --- /dev/null +++ b/internal/cmd/testdata/scripts/upgrade.txtar @@ -0,0 +1,8 @@ +[!env:CHEZMOI_GITHUB_TOKEN] skip '$CHEZMOI_GITHUB_TOKEN not set' + +# test that chezmoi upgrade succeeds +exec chezmoi upgrade --executable=$WORK${/}chezmoi$exe --method=replace-executable +exec $WORK/chezmoi$exe --version + +-- chezmoi -- +-- chezmoi.exe -- diff --git a/internal/cmd/testdata/scripts/vault.txt b/internal/cmd/testdata/scripts/vault.txtar similarity index 90% rename from internal/cmd/testdata/scripts/vault.txt rename to internal/cmd/testdata/scripts/vault.txtar index 5650a6b9888..e58f9f92afc 100644 --- a/internal/cmd/testdata/scripts/vault.txt +++ b/internal/cmd/testdata/scripts/vault.txtar @@ -1,9 +1,9 @@ -[!windows] chmod 755 bin/vault +[unix] chmod 755 bin/vault [windows] unix2dos bin/vault.cmd # test vault template function -chezmoi execute-template '{{ (vault "secret/examplesecret").data.data.password }}' -stdout examplepassword +exec chezmoi execute-template '{{ (vault "secret/examplesecret").data.data.password }}' +stdout ^examplepassword$ -- bin/vault -- #!/bin/sh diff --git a/internal/cmd/testdata/scripts/verify.txt b/internal/cmd/testdata/scripts/verify.txtar similarity index 59% rename from internal/cmd/testdata/scripts/verify.txt rename to internal/cmd/testdata/scripts/verify.txtar index 87a44cd9a49..d891d6c3ef0 100644 --- a/internal/cmd/testdata/scripts/verify.txt +++ b/internal/cmd/testdata/scripts/verify.txtar @@ -1,48 +1,50 @@ +[!umask:022] skip + mkhomedir golden mkhomedir mksourcedir # test that chezmoi verify succeeds -chezmoi verify +exec 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 +! exec chezmoi verify +exec chezmoi forget --force $HOME${/}.newfile +exec chezmoi verify # test that chezmoi verify fails when a file is edited edit $HOME/.file -! chezmoi verify -chezmoi apply --force $HOME${/}.file -chezmoi verify +! exec chezmoi verify +exec chezmoi apply --force $HOME${/}.file +exec 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 +! exec chezmoi verify +exec chezmoi apply --force $HOME${/}.file +exec chezmoi verify # test that chezmoi verify fails when a directory is removed from the destination directory rm $HOME/.dir -! chezmoi verify +! exec chezmoi verify mkdir $HOME/.dir -chezmoi apply --force $HOME${/}.dir -chezmoi verify +exec chezmoi apply --force $HOME${/}.dir +exec 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 +! exec chezmoi verify +exec chezmoi apply --force $HOME${/}.file +exec chezmoi verify # test that chezmoi verify fails when a directory's permissions are changed chmod 700 $HOME/.dir -! chezmoi verify -chezmoi apply --force $HOME${/}.dir -chezmoi verify +! exec chezmoi verify +exec chezmoi apply --force $HOME${/}.dir +exec chezmoi verify -- golden/dot_newfile -- # contents of .newfile diff --git a/internal/cmd/testdata/scripts/version.txt b/internal/cmd/testdata/scripts/version.txtar similarity index 59% rename from internal/cmd/testdata/scripts/version.txt rename to internal/cmd/testdata/scripts/version.txtar index 9176b2fa4eb..e6aa495d0a0 100644 --- a/internal/cmd/testdata/scripts/version.txt +++ b/internal/cmd/testdata/scripts/version.txtar @@ -1,2 +1,2 @@ -chezmoi --version +exec chezmoi --version stdout 'chezmoi version v2\.0\.0' diff --git a/internal/cmd/testdata/scripts/workingtree.txt b/internal/cmd/testdata/scripts/workingtree.txtar similarity index 77% rename from internal/cmd/testdata/scripts/workingtree.txt rename to internal/cmd/testdata/scripts/workingtree.txtar index 45473928f5f..ca390af6546 100644 --- a/internal/cmd/testdata/scripts/workingtree.txt +++ b/internal/cmd/testdata/scripts/workingtree.txtar @@ -4,18 +4,18 @@ mkhomedir # test that chezmoi cd creates the working tree if needed ! exists $CHEZMOISOURCEDIR -chezmoi cd +exec chezmoi cd exists $CHEZMOISOURCEDIR exists $CHEZMOISOURCEDIR/home # test that chezmoi add adds a file into the source directory -chezmoi add $HOME${/}.file +exec chezmoi add $HOME${/}.file cp golden/.file $CHEZMOISOURCEDIR/home/dot_file chhome home2/user # test chezmoi init --working-tree creates the correct directory -chezmoi init --working-tree=$HOME${/}.dotfiles --source=$HOME${/}.dotfiles${/}home +exec chezmoi init --working-tree=$HOME${/}.dotfiles --source=$HOME${/}.dotfiles${/}home exists $HOME/.dotfiles/.git exists $HOME/.dotfiles/home @@ -23,12 +23,9 @@ chhome home3/user # test that chezmoi add returns an error if the source directory is not in the working tree mkhomedir -! chezmoi add $HOME${/}.file +! exec chezmoi add $HOME${/}.file stderr 'not in' -# test that chezmoi docs does not return an error if the source directory is not in the working tree -chezmoi docs - -- golden/.file -- # contents of .file -- home/user/.config/chezmoi/chezmoi.toml -- diff --git a/internal/cmd/textconv.go b/internal/cmd/textconv.go new file mode 100644 index 00000000000..d4d799507ca --- /dev/null +++ b/internal/cmd/textconv.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "bytes" + "log/slog" + "os" + "os/exec" + + "github.com/bmatcuk/doublestar/v4" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +type textConvElement struct { + Pattern string `json:"pattern" mapstructure:"pattern" yaml:"pattern"` + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` +} + +type textConv []*textConvElement + +func (t textConv) convert(path string, data []byte) ([]byte, error) { + var longestPatternElement *textConvElement + for _, command := range t { + ok, err := doublestar.Match(command.Pattern, path) + if err != nil { + return nil, err + } + if !ok { + continue + } + if longestPatternElement == nil || len(command.Pattern) > len(longestPatternElement.Pattern) { + longestPatternElement = command + } + } + if longestPatternElement == nil { + return data, nil + } + + cmd := exec.Command(longestPatternElement.Command, longestPatternElement.Args...) //nolint:gosec + cmd.Stdin = bytes.NewReader(data) + cmd.Stderr = os.Stderr + return chezmoilog.LogCmdOutput(slog.Default(), cmd) +} diff --git a/internal/cmd/unmanagedcmd.go b/internal/cmd/unmanagedcmd.go index 9faac5844c1..f526a7c9854 100644 --- a/internal/cmd/unmanagedcmd.go +++ b/internal/cmd/unmanagedcmd.go @@ -1,51 +1,111 @@ package cmd import ( + "fmt" "io/fs" - "strings" + "sort" "github.com/spf13/cobra" - vfs "github.com/twpayne/go-vfs/v4" "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" ) +type unmanagedCmdConfig struct { + pathStyle chezmoi.PathStyle + tree bool +} + 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), + Use: "unmanaged [path]...", + Short: "List the unmanaged files in the destination directory", + Long: mustLongHelp("unmanaged"), + Example: example("unmanaged"), + Args: cobra.ArbitraryArgs, + RunE: c.makeRunEWithSourceState(c.runUnmanagedCmd), + Annotations: newAnnotations(), } + unmanagedCmd.Flags().VarP(&c.unmanaged.pathStyle, "path-style", "p", "Path style") + unmanagedCmd.Flags().BoolVarP(&c.unmanaged.tree, "tree", "t", c.unmanaged.tree, "Print paths as a tree") + return unmanagedCmd } func (c *Config) runUnmanagedCmd(cmd *cobra.Command, args []string, sourceState *chezmoi.SourceState) error { - builder := strings.Builder{} + var absPaths chezmoi.AbsPaths + if len(args) == 0 { + absPaths = append(absPaths, c.DestDirAbsPath) + } else { + argsAbsPaths := chezmoiset.New[chezmoi.AbsPath]() + for _, arg := range args { + argAbsPath, err := chezmoi.NormalizePath(arg) + if err != nil { + return err + } + argsAbsPaths.Add(argAbsPath) + } + absPaths = chezmoi.AbsPaths(argsAbsPaths.Elements()) + sort.Sort(absPaths) + } + + unmanagedRelPaths := chezmoiset.New[chezmoi.RelPath]() walkFunc := func(destAbsPath chezmoi.AbsPath, fileInfo fs.FileInfo, err error) error { if err != nil { - return err + c.errorf("%s: %v\n", destAbsPath, err) + if fileInfo == nil || fileInfo.IsDir() { + return fs.SkipDir + } + return nil } if destAbsPath == c.DestDirAbsPath { return nil } - targeRelPath := destAbsPath.MustTrimDirPrefix(c.DestDirAbsPath) - managed := sourceState.Contains(targeRelPath) - ignored := sourceState.Ignore(targeRelPath) + targetRelPath, err := destAbsPath.TrimDirPrefix(c.DestDirAbsPath) + if err != nil { + return err + } + sourceStateEntry := sourceState.Get(targetRelPath) + managed := sourceStateEntry != nil + ignored := sourceState.Ignore(targetRelPath) if !managed && !ignored { - builder.WriteString(targeRelPath.String()) - builder.WriteByte('\n') + unmanagedRelPaths.Add(targetRelPath) } - if fileInfo.IsDir() && (!managed || ignored) { - return vfs.SkipDir + if fileInfo.IsDir() { + switch { + case !managed: + return fs.SkipDir + case ignored: + return fs.SkipDir + case sourceStateEntry != nil: + if external, ok := sourceStateEntry.Origin().(*chezmoi.External); ok { + if external.Type == chezmoi.ExternalTypeGitRepo { + return fs.SkipDir + } + } + } } return nil } - if err := chezmoi.WalkSourceDir(c.destSystem, c.DestDirAbsPath, walkFunc); err != nil { - return err + for _, absPath := range absPaths { + if err := chezmoi.Walk(c.destSystem, absPath, walkFunc); err != nil { + return err + } + } + + paths := make([]fmt.Stringer, 0, len(unmanagedRelPaths.Elements())) + for relPath := range unmanagedRelPaths { + var path fmt.Stringer + if c.unmanaged.pathStyle == chezmoi.PathStyleAbsolute { + path = c.DestDirAbsPath.Join(relPath) + } else { + path = relPath + } + paths = append(paths, path) } - return c.writeOutputString(builder.String()) + + return c.writePaths(stringersToStrings(paths), writePathsOptions{ + tree: c.unmanaged.tree, + }) } diff --git a/internal/cmd/unmanagedcmd_test.go b/internal/cmd/unmanagedcmd_test.go new file mode 100644 index 00000000000..701a2a87611 --- /dev/null +++ b/internal/cmd/unmanagedcmd_test.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + + "github.com/twpayne/chezmoi/v2/internal/chezmoitest" +) + +func TestUnmanagedCmd(t *testing.T) { + for _, tc := range []struct { + name string + root any + postFunc func(vfs.FS) error + args []string + expectedOutput string + }{ + { + name: "simple", + root: map[string]any{ + "/home/user": map[string]any{ + ".file": "", + ".local/share/chezmoi/dot_file": "# contents of .file\n", + ".unmanaged": "", + }, + }, + expectedOutput: chezmoitest.JoinLines( + ".local", + ".unmanaged", + ), + }, + { + name: "private_subdir", + root: map[string]any{ + "/home/user": map[string]any{ + ".dir": map[string]any{ + "subdir/file": "", + }, + ".local/share/chezmoi/dot_dir/subdir/file": "", + }, + }, + postFunc: func(fileSystem vfs.FS) error { + return fileSystem.Chmod("/home/user/.dir", 0) + }, + args: []string{"--keep-going"}, + expectedOutput: chezmoitest.JoinLines( + ".local", + ), + }, + } { + t.Run(tc.name, func(t *testing.T) { + chezmoitest.WithTestFS(t, tc.root, func(fileSystem vfs.FS) { + if tc.postFunc != nil { + assert.NoError(t, tc.postFunc(fileSystem)) + } + stdout := &bytes.Buffer{} + config := newTestConfig(t, fileSystem, withStdout(stdout)) + assert.NoError(t, config.execute(append([]string{"unmanaged"}, tc.args...))) + assert.Equal(t, tc.expectedOutput, stdout.String()) + }) + }) + } +} diff --git a/internal/cmd/updatecmd.go b/internal/cmd/updatecmd.go index 13119927bca..3ad5555c8de 100644 --- a/internal/cmd/updatecmd.go +++ b/internal/cmd/updatecmd.go @@ -10,11 +10,13 @@ import ( ) type updateCmdConfig struct { - apply bool - exclude *chezmoi.EntryTypeSet - include *chezmoi.EntryTypeSet - init bool - recursive bool + Command string `json:"command" mapstructure:"command" yaml:"command"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` + Apply bool `json:"apply" mapstructure:"apply" yaml:"apply"` + RecurseSubmodules bool `json:"recurseSubmodules" mapstructure:"recurseSubmodules" yaml:"recurseSubmodules"` + filter *chezmoi.EntryTypeFilter + init bool + recursive bool } func (c *Config) newUpdateCmd() *cobra.Command { @@ -25,27 +27,33 @@ func (c *Config) newUpdateCmd() *cobra.Command { Example: example("update"), Args: cobra.NoArgs, RunE: c.runUpdateCmd, - Annotations: map[string]string{ - modifiesDestinationDirectory: "true", - persistentStateMode: persistentStateModeReadWrite, - requiresSourceDirectory: "true", - requiresWorkingTree: "true", - runsCommands: "true", - }, + Annotations: newAnnotations( + modifiesDestinationDirectory, + persistentStateModeReadWrite, + requiresSourceDirectory, + requiresWorkingTree, + runsCommands, + ), } - flags := updateCmd.Flags() - flags.BoolVarP(&c.update.apply, "apply", "a", c.update.apply, "Apply after pulling") - flags.VarP(c.update.exclude, "exclude", "x", "Exclude entry types") - flags.VarP(c.update.include, "include", "i", "Include entry types") - flags.BoolVar(&c.update.init, "init", c.update.init, "Recreate config file from template") - flags.BoolVarP(&c.update.recursive, "recursive", "r", c.update.recursive, "Recurse into subdirectories") + updateCmd.Flags().BoolVarP(&c.Update.Apply, "apply", "a", c.Update.Apply, "Apply after pulling") + updateCmd.Flags().VarP(c.Update.filter.Exclude, "exclude", "x", "Exclude entry types") + updateCmd.Flags().VarP(c.Update.filter.Include, "include", "i", "Include entry types") + updateCmd.Flags().BoolVar(&c.Update.init, "init", c.Update.init, "Recreate config file from template") + updateCmd.Flags(). + BoolVar(&c.Update.RecurseSubmodules, "recurse-submodules", c.Update.RecurseSubmodules, "Recursively update submodules") + updateCmd.Flags().BoolVarP(&c.Update.recursive, "recursive", "r", c.Update.recursive, "Recurse into subdirectories") return updateCmd } func (c *Config) runUpdateCmd(cmd *cobra.Command, args []string) error { - if c.UseBuiltinGit.Value(c.useBuiltinGitAutoFunc) { + switch { + case c.Update.Command != "": + if err := c.run(c.WorkingTreeAbsPath, c.Update.Command, c.Update.Args); err != nil { + return err + } + case c.UseBuiltinGit.Value(c.useBuiltinGitAutoFunc): rawWorkingTreeAbsPath, err := c.baseSystem.RawPath(c.WorkingTreeAbsPath) if err != nil { return err @@ -63,22 +71,28 @@ func (c *Config) runUpdateCmd(cmd *cobra.Command, args []string) error { }); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { return err } - } else { - args := []string{ + default: + gitArgs := []string{ "pull", + "--autostash", "--rebase", - "--recurse-submodules", } - if err := c.run(c.WorkingTreeAbsPath, c.Git.Command, args); err != nil { + if c.Update.RecurseSubmodules { + gitArgs = append(gitArgs, + "--recurse-submodules", + ) + } + if err := c.run(c.WorkingTreeAbsPath, c.Git.Command, gitArgs); err != nil { return err } } - if c.update.apply { + if c.Update.Apply { if err := c.applyArgs(cmd.Context(), c.destSystem, c.DestDirAbsPath, args, applyArgsOptions{ - include: c.update.include.Sub(c.update.exclude), - init: c.update.init, - recursive: c.update.recursive, + cmd: cmd, + filter: c.Update.filter, + init: c.Update.init, + recursive: c.Update.recursive, umask: c.Umask, preApplyFunc: c.defaultPreApplyFunc, }); err != nil { diff --git a/internal/cmd/upgradecmd.go b/internal/cmd/upgradecmd.go index ffb67740268..6f07df693c0 100644 --- a/internal/cmd/upgradecmd.go +++ b/internal/cmd/upgradecmd.go @@ -1,35 +1,31 @@ -//go:build !noupgrade && !windows -// +build !noupgrade,!windows +//go:build !noupgrade package cmd import ( - "archive/tar" "bufio" "bytes" - "compress/gzip" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" + "io/fs" + "log/slog" "net/http" "os" "os/exec" "regexp" "runtime" "strings" - "syscall" "github.com/coreos/go-semver/semver" - "github.com/google/go-github/v40/github" + "github.com/google/go-github/v63/github" "github.com/spf13/cobra" - vfs "github.com/twpayne/go-vfs/v4" - "go.uber.org/multierr" - "golang.org/x/sys/unix" "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) const ( @@ -38,53 +34,17 @@ const ( upgradeMethodSnapRefresh = "snap-refresh" upgradeMethodUpgradePackage = "upgrade-package" upgradeMethodSudoPrefix = "sudo-" - - libcTypeGlibc = "glibc" - libcTypeMusl = "musl" - - packageTypeNone = "" - packageTypeAPK = "apk" - packageTypeAUR = "aur" - packageTypeDEB = "deb" - packageTypeRPM = "rpm" + upgradeMethodWinGetUpgrade = "winget-upgrade" ) var ( - packageTypeByID = map[string]string{ - "alpine": packageTypeAPK, - "amzn": packageTypeRPM, - "arch": packageTypeAUR, - "centos": packageTypeRPM, - "fedora": packageTypeRPM, - "opensuse": packageTypeRPM, - "debian": packageTypeDEB, - "rhel": packageTypeRPM, - "sles": packageTypeRPM, - "ubuntu": packageTypeDEB, - } - - archReplacements = map[string]map[string]string{ - packageTypeDEB: { - "386": "i386", - "arm": "armel", - }, - packageTypeRPM: { - "amd64": "x86_64", - "386": "i686", - "arm": "armhfp", - "arm64": "aarch64", - }, - } - - checksumRx = regexp.MustCompile(`\A([0-9a-f]{64})\s+(\S+)\z`) - libcTypeGlibcRx = regexp.MustCompile(`(?i)glibc|gnu libc`) - libcTypeMuslRx = regexp.MustCompile(`(?i)musl`) + checksumRx = regexp.MustCompile(`\A([0-9a-f]{64})\s+(\S+)\z`) + errUnsupportedUpgradeMethod = errors.New("unsupported upgrade method") ) type upgradeCmdConfig struct { - method string - owner string - repo string + executable string + method string } func (c *Config) newUpgradeCmd() *cobra.Command { @@ -95,15 +55,13 @@ func (c *Config) newUpgradeCmd() *cobra.Command { Example: example("upgrade"), Args: cobra.NoArgs, RunE: c.runUpgradeCmd, - Annotations: map[string]string{ - runsCommands: "true", - }, + Annotations: newAnnotations( + runsCommands, + ), } - flags := upgradeCmd.Flags() - flags.StringVar(&c.upgrade.method, "method", c.upgrade.method, "Set upgrade method") - flags.StringVar(&c.upgrade.owner, "owner", c.upgrade.owner, "Set owner") - flags.StringVar(&c.upgrade.repo, "repo", c.upgrade.repo, "Set repo") + upgradeCmd.Flags().StringVar(&c.upgrade.executable, "executable", c.upgrade.method, "Set executable to replace") + upgradeCmd.Flags().StringVar(&c.upgrade.method, "method", c.upgrade.method, "Set upgrade method") return upgradeCmd } @@ -112,7 +70,8 @@ func (c *Config) runUpgradeCmd(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - if c.version == nil && !c.force { + var zeroVersion semver.Version + if c.version == zeroVersion && !c.force { return errors.New("cannot upgrade dev version to latest released version unless --force is set") } @@ -120,10 +79,10 @@ func (c *Config) runUpgradeCmd(cmd *cobra.Command, args []string) error { if err != nil { return err } - client := newGitHubClient(ctx, httpClient) + client := chezmoi.NewGitHubClient(ctx, httpClient) // Get the latest release. - rr, _, err := client.Repositories.GetLatestRelease(ctx, c.upgrade.owner, c.upgrade.repo) + rr, _, err := client.Repositories.GetLatestRelease(ctx, "twpayne", "chezmoi") if err != nil { return err } @@ -140,11 +99,15 @@ func (c *Config) runUpgradeCmd(cmd *cobra.Command, args []string) error { } // Determine the upgrade method to use. - executable, err := os.Executable() - if err != nil { - return err + if c.upgrade.executable == "" { + executable, err := os.Executable() + if err != nil { + return err + } + c.upgrade.executable = executable } - executableAbsPath := chezmoi.NewAbsPath(executable) + + executableAbsPath := chezmoi.NewAbsPath(c.upgrade.executable) method := c.upgrade.method if method == "" { switch method, err = getUpgradeMethod(c.fileSystem, executableAbsPath); { @@ -154,10 +117,7 @@ func (c *Config) runUpgradeCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("%s/%s: cannot determine upgrade method for %s", runtime.GOOS, runtime.GOARCH, executableAbsPath) } } - c.logger.Info(). - Str("executable", executable). - Str("method", method). - Msg("upgradeMethod") + c.logger.Info("upgradeMethod", slog.String("executable", c.upgrade.executable), slog.String("method", method)) // Replace the executable with the updated version. switch method { @@ -175,12 +135,16 @@ func (c *Config) runUpgradeCmd(cmd *cobra.Command, args []string) error { } case upgradeMethodUpgradePackage: useSudo := false - if err := c.upgradePackage(ctx, version, rr, useSudo); err != nil { + if err := c.upgradeUNIXPackage(ctx, version, rr, useSudo); err != nil { return err } case upgradeMethodSudoPrefix + upgradeMethodUpgradePackage: useSudo := true - if err := c.upgradePackage(ctx, version, rr, useSudo); err != nil { + if err := c.upgradeUNIXPackage(ctx, version, rr, useSudo); err != nil { + return err + } + case upgradeMethodWinGetUpgrade: + if err := c.winGetUpgrade(); err != nil { return err } default: @@ -189,34 +153,24 @@ func (c *Config) runUpgradeCmd(cmd *cobra.Command, args []string) error { // Find the executable. If we replaced the executable directly, then use // that, otherwise look in $PATH. - path := executable + path := c.upgrade.executable if method != upgradeMethodReplaceExecutable { - path, err = exec.LookPath(c.upgrade.repo) + path, err = chezmoi.LookPath("chezmoi") if err != nil { return err } } // Execute the new version. - arg0 := path - argv := []string{arg0, "--version"} - c.logger.Info(). - Str("arg0", arg0). - Strs("argv", argv). - Msg("exec") - err = unix.EINTR - for errors.Is(err, unix.EINTR) { - err = unix.Exec(arg0, argv, os.Environ()) - } - return err -} - -func (c *Config) brewUpgrade() error { - return c.run(chezmoi.EmptyAbsPath, "brew", []string{"upgrade", c.upgrade.repo}) + chezmoiVersionCmd := exec.Command(path, "--version") + chezmoiVersionCmd.Stdin = os.Stdin + chezmoiVersionCmd.Stdout = os.Stdout + chezmoiVersionCmd.Stderr = os.Stderr + return chezmoilog.LogCmdRun(c.logger, chezmoiVersionCmd) } func (c *Config) getChecksums(ctx context.Context, rr *github.RepositoryRelease) (map[string][]byte, error) { - name := fmt.Sprintf("%s_%s_checksums.txt", c.upgrade.repo, strings.TrimPrefix(rr.GetTagName(), "v")) + name := fmt.Sprintf("chezmoi_%s_checksums.txt", strings.TrimPrefix(rr.GetTagName(), "v")) releaseAsset := getReleaseAssetByName(rr, name) if releaseAsset == nil { return nil, fmt.Errorf("%s: cannot find release asset", name) @@ -240,7 +194,7 @@ func (c *Config) getChecksums(ctx context.Context, rr *github.RepositoryRelease) } func (c *Config) downloadURL(ctx context.Context, url string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return nil, err } @@ -248,26 +202,13 @@ func (c *Config) downloadURL(ctx context.Context, url string) ([]byte, error) { if err != nil { return nil, err } - resp, err := httpClient.Do(req) - if resp != nil { - c.logger.Err(err). - Str("method", req.Method). - Int("statusCode", resp.StatusCode). - Str("status", resp.Status). - Stringer("url", req.URL). - Msg("HTTP") - } else { - c.logger.Err(err). - Str("method", req.Method). - Stringer("url", req.URL). - Msg("HTTP") - } + resp, err := chezmoilog.LogHTTPRequest(ctx, c.logger, httpClient, req) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { _ = resp.Body.Close() - return nil, fmt.Errorf("%s: got a non-200 OK response: %d %s", url, resp.StatusCode, resp.Status) + return nil, fmt.Errorf("%s: %s", url, resp.Status) } data, err := io.ReadAll(resp.Body) if err != nil { @@ -279,174 +220,80 @@ func (c *Config) downloadURL(ctx context.Context, url string) ([]byte, error) { return data, nil } -// getLibc attempts to determine the system's libc. -func (c *Config) getLibc() (string, error) { - // First, try parsing the output of ldd --version. On glibc systems it - // writes to stdout and exits with code 0. On musl libc systems it writes to - // stderr and exits with code 1. - lddCmd := exec.Command("ldd", "--version") - switch output, _ := c.baseSystem.IdempotentCmdCombinedOutput(lddCmd); { - case libcTypeGlibcRx.Match(output): - return libcTypeGlibc, nil - case libcTypeMuslRx.Match(output): - return libcTypeMusl, nil - } - - // Second, try getconf GNU_LIBC_VERSION. - getconfCmd := exec.Command("getconf", "GNU_LIBC_VERSION") - if output, err := c.baseSystem.IdempotentCmdOutput(getconfCmd); err != nil { - if libcTypeGlibcRx.Match(output) { - return libcTypeGlibc, nil - } - } - - return "", errors.New("unable to determine libc") -} - -func (c *Config) getPackageFilename(packageType string, version *semver.Version, os, arch string) (string, error) { - if archReplacement, ok := archReplacements[packageType][arch]; ok { - arch = archReplacement - } - switch packageType { - case packageTypeAPK: - return fmt.Sprintf("%s_%s_%s_%s.apk", c.upgrade.repo, version, os, arch), nil - case packageTypeDEB: - return fmt.Sprintf("%s_%s_%s_%s.deb", c.upgrade.repo, version, os, arch), nil - case packageTypeRPM: - return fmt.Sprintf("%s-%s-%s.rpm", c.upgrade.repo, version, arch), nil - default: - return "", fmt.Errorf("%s: unsupported package type", packageType) - } -} - func (c *Config) replaceExecutable( - ctx context.Context, executableFilenameAbsPath chezmoi.AbsPath, releaseVersion *semver.Version, + ctx context.Context, + executableFilenameAbsPath chezmoi.AbsPath, + releaseVersion *semver.Version, rr *github.RepositoryRelease, ) (err error) { - goos := runtime.GOOS - if goos == "linux" && runtime.GOARCH == "amd64" { + var archiveFormat chezmoi.ArchiveFormat + var archiveName string + switch { + case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": + archiveFormat = chezmoi.ArchiveFormatTarGz var libc string - if libc, err = c.getLibc(); err != nil { + if libc, err = getLibc(); err != nil { return } - goos += "-" + libc + archiveName = fmt.Sprintf("chezmoi_%s_%s-%s_%s.tar.gz", releaseVersion, runtime.GOOS, libc, runtime.GOARCH) + case runtime.GOOS == "linux" && runtime.GOARCH == "386": + archiveFormat = chezmoi.ArchiveFormatTarGz + archiveName = fmt.Sprintf("chezmoi_%s_%s_i386.tar.gz", releaseVersion, runtime.GOOS) + case runtime.GOOS == "windows": + archiveFormat = chezmoi.ArchiveFormatZip + archiveName = fmt.Sprintf("chezmoi_%s_%s_%s.zip", releaseVersion, runtime.GOOS, runtime.GOARCH) + default: + archiveFormat = chezmoi.ArchiveFormatTarGz + archiveName = fmt.Sprintf("chezmoi_%s_%s_%s.tar.gz", releaseVersion, runtime.GOOS, runtime.GOARCH) } - name := fmt.Sprintf("%s_%s_%s_%s.tar.gz", c.upgrade.repo, releaseVersion, goos, runtime.GOARCH) - releaseAsset := getReleaseAssetByName(rr, name) + releaseAsset := getReleaseAssetByName(rr, archiveName) if releaseAsset == nil { - err = fmt.Errorf("%s: cannot find release asset", name) + err = fmt.Errorf("%s: cannot find release asset", archiveName) return } - var data []byte - if data, err = c.downloadURL(ctx, releaseAsset.GetBrowserDownloadURL()); err != nil { - return err + var archiveData []byte + if archiveData, err = c.downloadURL(ctx, releaseAsset.GetBrowserDownloadURL()); err != nil { + return } - if err = c.verifyChecksum(ctx, rr, releaseAsset.GetName(), data); err != nil { - return err + if err = c.verifyChecksum(ctx, rr, releaseAsset.GetName(), archiveData); err != nil { + return } // Extract the executable from the archive. - var gzipReader *gzip.Reader - if gzipReader, err = gzip.NewReader(bytes.NewReader(data)); err != nil { - return err - } - defer func() { - err = multierr.Append(err, gzipReader.Close()) - }() - tarReader := tar.NewReader(gzipReader) var executableData []byte -FOR: - for { - var header *tar.Header - switch header, err = tarReader.Next(); { - case err == nil && header.Name == c.upgrade.repo: - if executableData, err = io.ReadAll(tarReader); err != nil { - return + executableName := "chezmoi" + if runtime.GOOS == "windows" { + executableName += ".exe" + } + walkArchiveFunc := func(name string, info fs.FileInfo, r io.Reader, linkname string) error { + if name == executableName { + var err error + executableData, err = io.ReadAll(r) + if err != nil { + return err } - break FOR - case errors.Is(err, io.EOF): - err = fmt.Errorf("%s: could not find header", c.upgrade.repo) - return + return fs.SkipAll } + return nil + } + if err = chezmoi.WalkArchive(archiveData, archiveFormat, walkArchiveFunc); err != nil { + return + } + if executableData == nil { + err = fmt.Errorf("%s: cannot find executable in archive", archiveName) + return } - err = c.baseSystem.WriteFile(executableFilenameAbsPath, executableData, 0o755) - return -} - -func (c *Config) snapRefresh() error { - return c.run(chezmoi.EmptyAbsPath, "snap", []string{"refresh", c.upgrade.repo}) -} - -func (c *Config) upgradePackage( - ctx context.Context, version *semver.Version, rr *github.RepositoryRelease, useSudo bool, -) error { - switch runtime.GOOS { - case "linux": - // Determine the package type and architecture. - packageType, err := getPackageType(c.baseSystem) - if err != nil { - return err - } - - // chezmoi does not build and distribute AUR packages, so instead rely - // on pacman and the community package. - if packageType == packageTypeAUR { - var args []string - if useSudo { - args = append(args, "sudo") - } - args = append(args, "pacman", "-S", c.upgrade.repo) - return c.run(chezmoi.EmptyAbsPath, args[0], args[1:]) - } - - // Find the release asset. - packageFilename, err := c.getPackageFilename(packageType, version, runtime.GOOS, runtime.GOARCH) - if err != nil { - return err - } - releaseAsset := getReleaseAssetByName(rr, packageFilename) - if releaseAsset == nil { - return fmt.Errorf("%s: cannot find release asset", packageFilename) - } - - // Create a temporary directory for the package. - tempDirAbsPath, err := c.tempDir("chezmoi") - if err != nil { - return err - } - - data, err := c.downloadURL(ctx, releaseAsset.GetBrowserDownloadURL()) - if err != nil { - return err - } - if err := c.verifyChecksum(ctx, rr, releaseAsset.GetName(), data); err != nil { - return err - } - - packageAbsPath := tempDirAbsPath.JoinString(releaseAsset.GetName()) - if err := c.baseSystem.WriteFile(packageAbsPath, data, 0o644); err != nil { + // Replace the executable. + if runtime.GOOS == "windows" { + if err := c.baseSystem.Rename(executableFilenameAbsPath, executableFilenameAbsPath.Append(".old")); err != nil { return err } - - // Install the package from disk. - var args []string - if useSudo { - args = append(args, "sudo") - } - switch packageType { - case packageTypeAPK: - args = append(args, "apk", "--allow-untrusted", packageAbsPath.String()) - case packageTypeDEB: - args = append(args, "dpkg", "-i", packageAbsPath.String()) - case packageTypeRPM: - args = append(args, "rpm", "-U", packageAbsPath.String()) - } - return c.run(chezmoi.EmptyAbsPath, args[0], args[1:]) - default: - return fmt.Errorf("%s: unsupported GOOS", runtime.GOOS) } + err = c.baseSystem.WriteFile(executableFilenameAbsPath, executableData, 0o755) + + return } func (c *Config) verifyChecksum(ctx context.Context, rr *github.RepositoryRelease, name string, data []byte) error { @@ -460,103 +307,12 @@ func (c *Config) verifyChecksum(ctx context.Context, rr *github.RepositoryReleas } checksum := sha256.Sum256(data) if !bytes.Equal(checksum[:], expectedChecksum) { - return fmt.Errorf( - "%s: checksum failed (want %s, got %s)", name, hex.EncodeToString(expectedChecksum), hex.EncodeToString(checksum[:]), - ) + format := "%s: checksum failed (want %s, got %s)" + return fmt.Errorf(format, name, hex.EncodeToString(expectedChecksum), hex.EncodeToString(checksum[:])) } return nil } -// getUpgradeMethod attempts to determine the method by which chezmoi can be -// upgraded by looking at how it was installed. -func getUpgradeMethod(fileSystem vfs.Stater, executableAbsPath chezmoi.AbsPath) (string, error) { - switch { - case runtime.GOOS == "darwin" && strings.Contains(executableAbsPath.String(), "/homebrew/"): - return upgradeMethodBrewUpgrade, nil - case runtime.GOOS == "linux" && strings.Contains(executableAbsPath.String(), "/.linuxbrew/"): - return upgradeMethodBrewUpgrade, nil - } - - // If the executable is in the user's home directory, then always use - // replace-executable. - userHomeDir, err := os.UserHomeDir() - if err != nil { - return "", err - } - if executableInUserHomeDir, err := vfs.Contains(fileSystem, executableAbsPath.String(), userHomeDir); err != nil { - return "", err - } else if executableInUserHomeDir { - return upgradeMethodReplaceExecutable, nil - } - - // If the executable is in the system's temporary directory, then always use - // replace-executable. - if executableIsInTempDir, err := vfs.Contains(fileSystem, executableAbsPath.String(), os.TempDir()); err != nil { - return "", err - } else if executableIsInTempDir { - return upgradeMethodReplaceExecutable, nil - } - - switch runtime.GOOS { - case "darwin": - return upgradeMethodReplaceExecutable, nil - case "freebsd": - return upgradeMethodReplaceExecutable, nil - case "linux": - if ok, _ := vfs.Contains(fileSystem, executableAbsPath.String(), "/snap"); ok { - return upgradeMethodSnapRefresh, nil - } - - fileInfo, err := fileSystem.Stat(executableAbsPath.String()) - if err != nil { - return "", err - } - //nolint:forcetypeassert - executableStat := fileInfo.Sys().(*syscall.Stat_t) - uid := os.Getuid() - switch int(executableStat.Uid) { - case 0: - method := upgradeMethodUpgradePackage - if uid != 0 { - if _, err := exec.LookPath("sudo"); err == nil { - method = upgradeMethodSudoPrefix + method - } - } - return method, nil - case uid: - return upgradeMethodReplaceExecutable, nil - default: - return "", fmt.Errorf("%s: cannot upgrade executable owned by non-current non-root user", executableAbsPath) - } - case "openbsd": - return upgradeMethodReplaceExecutable, nil - default: - return "", nil - } -} - -// getPackageType returns the distributions package type based on is OS release. -func getPackageType(system chezmoi.System) (string, error) { - osRelease, err := chezmoi.OSRelease(system) - if err != nil { - return packageTypeNone, err - } - if id, ok := osRelease["ID"].(string); ok { - if packageType, ok := packageTypeByID[id]; ok { - return packageType, nil - } - } - if idLikes, ok := osRelease["ID_LIKE"].(string); ok { - for _, id := range strings.Split(idLikes, " ") { - if packageType, ok := packageTypeByID[id]; ok { - return packageType, nil - } - } - } - err = fmt.Errorf("could not determine package type (ID=%q, ID_LIKE=%q)", osRelease["ID"], osRelease["ID_LIKE"]) - return packageTypeNone, err -} - // getReleaseAssetByName returns the release asset from rr with the given name. func getReleaseAssetByName(rr *github.RepositoryRelease, name string) *github.ReleaseAsset { for i, ra := range rr.Assets { diff --git a/internal/cmd/upgradecmd_test.go b/internal/cmd/upgradecmd_test.go index 6fcba08ec27..d6bd232157e 100644 --- a/internal/cmd/upgradecmd_test.go +++ b/internal/cmd/upgradecmd_test.go @@ -1,15 +1,13 @@ -//go:build !noupgrade && !windows -// +build !noupgrade,!windows +//go:build !noupgrade && unix package cmd import ( "testing" + "github.com/alecthomas/assert/v2" "github.com/coreos/go-semver/semver" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/twpayne/go-vfs/v4" + "github.com/twpayne/go-vfs/v5" ) func TestConfigGetPackageFilename(t *testing.T) { @@ -78,7 +76,7 @@ func TestConfigGetPackageFilename(t *testing.T) { c := newTestConfig(t, vfs.EmptyFS{}) version := semver.Must(semver.NewVersion("2.0.0")) actual, err := c.getPackageFilename(tc.packageType, version, "linux", tc.arch) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, tc.expected, actual) }) } diff --git a/internal/cmd/upgradecmd_unix.go b/internal/cmd/upgradecmd_unix.go new file mode 100644 index 00000000000..d6ec6075898 --- /dev/null +++ b/internal/cmd/upgradecmd_unix.go @@ -0,0 +1,288 @@ +//go:build !noupgrade && unix + +package cmd + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "os/exec" + "regexp" + "runtime" + "strings" + + "github.com/coreos/go-semver/semver" + "github.com/google/go-github/v63/github" + vfs "github.com/twpayne/go-vfs/v5" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" +) + +const ( + libcTypeGlibc = "glibc" + libcTypeMusl = "musl" + + packageTypeNone = "" + packageTypeAPK = "apk" + packageTypeAUR = "aur" + packageTypeDEB = "deb" + packageTypeRPM = "rpm" +) + +var ( + packageTypeByID = map[string]string{ + "alpine": packageTypeAPK, + "amzn": packageTypeRPM, + "arch": packageTypeAUR, + "centos": packageTypeRPM, + "fedora": packageTypeRPM, + "opensuse": packageTypeRPM, + "debian": packageTypeDEB, + "rhel": packageTypeRPM, + "sles": packageTypeRPM, + "ubuntu": packageTypeDEB, + } + + archReplacements = map[string]map[string]string{ + packageTypeDEB: { + "386": "i386", + "arm": "armel", + }, + packageTypeRPM: { + "amd64": "x86_64", + "386": "i686", + "arm": "armhfp", + "arm64": "aarch64", + }, + } + + libcTypeGlibcRx = regexp.MustCompile(`(?i)glibc|gnu libc`) + libcTypeMuslRx = regexp.MustCompile(`(?i)musl`) +) + +func (c *Config) brewUpgrade() error { + return c.run(chezmoi.EmptyAbsPath, "brew", []string{"upgrade", "chezmoi"}) +} + +func (c *Config) getPackageFilename(packageType string, version *semver.Version, os, arch string) (string, error) { + if archReplacement, ok := archReplacements[packageType][arch]; ok { + arch = archReplacement + } + switch packageType { + case packageTypeAPK: + return fmt.Sprintf("chezmoi_%s_%s_%s.apk", version, os, arch), nil + case packageTypeDEB: + return fmt.Sprintf("chezmoi_%s_%s_%s.deb", version, os, arch), nil + case packageTypeRPM: + return fmt.Sprintf("chezmoi-%s-%s.rpm", version, arch), nil + default: + return "", fmt.Errorf("%s: unsupported package type", packageType) + } +} + +func (c *Config) snapRefresh() error { + return c.run(chezmoi.EmptyAbsPath, "snap", []string{"refresh", "chezmoi"}) +} + +func (c *Config) upgradeUNIXPackage( + ctx context.Context, + version *semver.Version, + rr *github.RepositoryRelease, + useSudo bool, +) error { + switch runtime.GOOS { + case "linux": + // Determine the package type and architecture. + packageType, err := getPackageType(c.baseSystem) + if err != nil { + return err + } + + // chezmoi does not build and distribute AUR packages, so instead rely + // on pacman and the community package. + if packageType == packageTypeAUR { + var args []string + if useSudo { + args = append(args, "sudo") + } + args = append(args, "pacman", "-S", "--needed", "chezmoi") + return c.run(chezmoi.EmptyAbsPath, args[0], args[1:]) + } + + // Find the release asset. + packageFilename, err := c.getPackageFilename( + packageType, + version, + runtime.GOOS, + runtime.GOARCH, + ) + if err != nil { + return err + } + releaseAsset := getReleaseAssetByName(rr, packageFilename) + if releaseAsset == nil { + return fmt.Errorf("%s: cannot find release asset", packageFilename) + } + + // Create a temporary directory for the package. + tempDirAbsPath, err := c.tempDir("chezmoi") + if err != nil { + return err + } + + data, err := c.downloadURL(ctx, releaseAsset.GetBrowserDownloadURL()) + if err != nil { + return err + } + if err := c.verifyChecksum(ctx, rr, releaseAsset.GetName(), data); err != nil { + return err + } + + packageAbsPath := tempDirAbsPath.JoinString(releaseAsset.GetName()) + if err := c.baseSystem.WriteFile(packageAbsPath, data, 0o644); err != nil { + return err + } + + // Install the package from disk. + var args []string + if useSudo { + args = append(args, "sudo") + } + switch packageType { + case packageTypeAPK: + args = append(args, "apk", "--allow-untrusted", packageAbsPath.String()) + case packageTypeDEB: + args = append(args, "dpkg", "-i", packageAbsPath.String()) + case packageTypeRPM: + args = append(args, "rpm", "-U", packageAbsPath.String()) + } + return c.run(chezmoi.EmptyAbsPath, args[0], args[1:]) + default: + return fmt.Errorf("%s: unsupported GOOS", runtime.GOOS) + } +} + +func (c *Config) winGetUpgrade() error { + return errUnsupportedUpgradeMethod +} + +// getLibc attempts to determine the system's libc. +func getLibc() (string, error) { + // First, try parsing the output of ldd --version. On glibc systems it + // writes to stdout and exits with code 0. On musl libc systems it writes to + // stderr and exits with code 1. + lddCmd := exec.Command("ldd", "--version") + switch output, _ := chezmoilog.LogCmdCombinedOutput(slog.Default(), lddCmd); { + case libcTypeGlibcRx.Match(output): + return libcTypeGlibc, nil + case libcTypeMuslRx.Match(output): + return libcTypeMusl, nil + } + + // Second, try getconf GNU_LIBC_VERSION. + getconfCmd := exec.Command("getconf", "GNU_LIBC_VERSION") + if output, _ := chezmoilog.LogCmdCombinedOutput(slog.Default(), getconfCmd); libcTypeGlibcRx.Match(output) { + return libcTypeGlibc, nil + } + + return "", errors.New("unable to determine libc") +} + +// getPackageType returns the distributions package type based on is OS release. +func getPackageType(system chezmoi.System) (string, error) { + osRelease, err := chezmoi.OSRelease(system.UnderlyingFS()) + if err != nil { + return packageTypeNone, err + } + if id, ok := osRelease["ID"].(string); ok { + if packageType, ok := packageTypeByID[id]; ok { + return packageType, nil + } + } + if idLikes, ok := osRelease["ID_LIKE"].(string); ok { + for _, id := range strings.Split(idLikes, " ") { + if packageType, ok := packageTypeByID[id]; ok { + return packageType, nil + } + } + } + err = fmt.Errorf( + "could not determine package type (ID=%q, ID_LIKE=%q)", + osRelease["ID"], + osRelease["ID_LIKE"], + ) + return packageTypeNone, err +} + +// getUpgradeMethod attempts to determine the method by which chezmoi can be +// upgraded by looking at how it was installed. +func getUpgradeMethod(fileSystem vfs.Stater, executableAbsPath chezmoi.AbsPath) (string, error) { + switch { + case runtime.GOOS == "darwin" && strings.Contains(executableAbsPath.String(), "/homebrew/"): + return upgradeMethodBrewUpgrade, nil + case runtime.GOOS == "linux" && strings.Contains(executableAbsPath.String(), "/.linuxbrew/"): + return upgradeMethodBrewUpgrade, nil + } + + // If the executable is in the user's home directory, then always use + // replace-executable. + switch userHomeDir, err := chezmoi.UserHomeDir(); { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + return "", err + default: + switch executableInUserHomeDir, err := vfs.Contains(fileSystem, executableAbsPath.String(), userHomeDir); { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + return "", err + case executableInUserHomeDir: + return upgradeMethodReplaceExecutable, nil + } + } + + // If the executable is in the system's temporary directory, then always use + // replace-executable. + if executableIsInTempDir, err := vfs.Contains(fileSystem, executableAbsPath.String(), os.TempDir()); err != nil { + return "", err + } else if executableIsInTempDir { + return upgradeMethodReplaceExecutable, nil + } + + switch runtime.GOOS { + case "darwin": + return upgradeMethodReplaceExecutable, nil + case "freebsd": + return upgradeMethodReplaceExecutable, nil + case "linux": + if ok, _ := vfs.Contains(fileSystem, executableAbsPath.String(), "/snap"); ok { + return upgradeMethodSnapRefresh, nil + } + fileInfo, err := fileSystem.Stat(executableAbsPath.String()) + if err != nil { + return "", err + } + uid := os.Getuid() + switch fileInfoUID(fileInfo) { + case 0: + method := upgradeMethodUpgradePackage + if uid != 0 { + if _, err := chezmoi.LookPath("sudo"); err == nil { + method = upgradeMethodSudoPrefix + method + } + } + return method, nil + case uid: + return upgradeMethodReplaceExecutable, nil + default: + return "", fmt.Errorf("%s: cannot upgrade executable owned by non-current non-root user", executableAbsPath) + } + case "openbsd": + return upgradeMethodReplaceExecutable, nil + default: + return "", nil + } +} diff --git a/internal/cmd/upgradecmd_windows.go b/internal/cmd/upgradecmd_windows.go new file mode 100644 index 00000000000..1f14c235cf9 --- /dev/null +++ b/internal/cmd/upgradecmd_windows.go @@ -0,0 +1,137 @@ +//go:build !noupgrade + +package cmd + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + + "github.com/coreos/go-semver/semver" + "github.com/google/go-github/v63/github" + vfs "github.com/twpayne/go-vfs/v5" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +type InstallBehavior struct { + PortablePackageUserRoot string `json:"portablePackageUserRoot"` + PortablePackageMachineRoot string `json:"portablePackageMachineRoot"` +} + +func (ib *InstallBehavior) Values() []string { + return []string{ + ib.PortablePackageUserRoot, + ib.PortablePackageMachineRoot, + } +} + +type WinGetSettings struct { + InstallBehavior InstallBehavior `json:"installBehavior"` +} + +func (c *Config) brewUpgrade() error { + return errUnsupportedUpgradeMethod +} + +// isWinGetInstall determines if executableAbsPath contains a WinGet installation path. +func isWinGetInstall(fileSystem vfs.Stater, executableAbsPath string) (bool, error) { + realExecutableAbsPath := executableAbsPath + fi, err := os.Lstat(executableAbsPath) + if err != nil { + return false, err + } + if fi.Mode().Type() == fs.ModeSymlink { + realExecutableAbsPath, err = os.Readlink(executableAbsPath) + if err != nil { + return false, err + } + } + winGetSettings := WinGetSettings{ + InstallBehavior: InstallBehavior{ + PortablePackageUserRoot: os.ExpandEnv(`${LOCALAPPDATA}\Microsoft\WinGet\Packages\`), + PortablePackageMachineRoot: os.ExpandEnv(`${PROGRAMFILES}\WinGet\Packages\`), + }, + } + settingsPaths := []string{ + os.ExpandEnv(`${LOCALAPPDATA}\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\settings.json`), + os.ExpandEnv(`${LOCALAPPDATA}\Microsoft\WinGet\Settings\settings.json`), + } + for _, settingsPath := range settingsPaths { + if _, err := os.Stat(settingsPath); err == nil { + winGetSettingsContents, err := os.ReadFile(settingsPath) + if err == nil { + if err := chezmoi.FormatJSONC.Unmarshal(winGetSettingsContents, &winGetSettings); err != nil { + return false, err + } + } + } + } + for _, path := range winGetSettings.InstallBehavior.Values() { + path = filepath.Clean(path) + if path == "." { + continue + } + if ok, _ := vfs.Contains(fileSystem, realExecutableAbsPath, path); ok { + return true, nil + } + } + return false, nil +} + +func (c *Config) snapRefresh() error { + return errUnsupportedUpgradeMethod +} + +func (c *Config) upgradeUNIXPackage(_ context.Context, _ *semver.Version, _ *github.RepositoryRelease, _ bool) error { + return errUnsupportedUpgradeMethod +} + +func (c *Config) winGetUpgrade() error { + return errors.New( + "upgrade command is not currently supported for WinGet installations. chezmoi can still be upgraded via WinGet by running `winget upgrade --id twpayne.chezmoi --source winget`", + ) +} + +// getLibc attempts to determine the system's libc. +func getLibc() (string, error) { + return "", nil +} + +// getUpgradeMethod attempts to determine the method by which chezmoi can be +// upgraded by looking at how it was installed. +func getUpgradeMethod(fileSystem vfs.Stater, executableAbsPath chezmoi.AbsPath) (string, error) { + if ok, err := isWinGetInstall(fileSystem, executableAbsPath.String()); err != nil { + return "", err + } else if ok { + return upgradeMethodWinGetUpgrade, nil + } + + // If the executable is in the user's home directory, then always use + // replace-executable. + switch userHomeDir, err := chezmoi.UserHomeDir(); { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + return "", err + default: + switch executableInUserHomeDir, err := vfs.Contains(fileSystem, executableAbsPath.String(), userHomeDir); { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + return "", err + case executableInUserHomeDir: + return upgradeMethodReplaceExecutable, nil + } + } + + // If the executable is in the system's temporary directory, then always use + // replace-executable. + if executableIsInTempDir, err := vfs.Contains(fileSystem, executableAbsPath.String(), os.TempDir()); err != nil { + return "", err + } else if executableIsInTempDir { + return upgradeMethodReplaceExecutable, nil + } + + return "", nil +} diff --git a/internal/cmd/util.go b/internal/cmd/util.go index 3a74b8e1dc2..2a90131b852 100644 --- a/internal/cmd/util.go +++ b/internal/cmd/util.go @@ -1,26 +1,20 @@ package cmd import ( - "context" "fmt" - "net/http" - "os" - "regexp" - "strconv" "strings" "unicode" - "github.com/google/go-github/v40/github" - "golang.org/x/oauth2" + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" ) var ( - wellKnownAbbreviations = map[string]struct{}{ - "ANSI": {}, - "CPE": {}, - "ID": {}, - "URL": {}, - } + wellKnownAbbreviations = chezmoiset.New( + "ANSI", + "CPE", + "ID", + "URL", + ) choicesYesNoAllQuit = []string{ "yes", @@ -28,8 +22,42 @@ var ( "all", "quit", } + choicesYesNoQuit = []string{ + "yes", + "no", + "quit", + } ) +// camelCaseToUpperSnakeCase converts a string in camelCase to UPPER_SNAKE_CASE. +func camelCaseToUpperSnakeCase(s string) string { + if s == "" { + return "" + } + + runes := []rune(s) + var wordBoundaries []int + for i, r := range runes[1:] { + if unicode.IsLower(runes[i]) && unicode.IsUpper(r) { + wordBoundaries = append(wordBoundaries, i+1) + } + } + + if len(wordBoundaries) == 0 { + return strings.ToUpper(s) + } + + wordBoundaries = append(wordBoundaries, len(runes)) + words := make([]string, len(wordBoundaries)) + prevWordBoundary := 0 + for i, wordBoundary := range wordBoundaries { + word := string(runes[prevWordBoundary:wordBoundary]) + words[i] = strings.ToUpper(word) + prevWordBoundary = wordBoundary + } + return strings.Join(words, "_") +} + // englishList returns ss formatted as a list, including an Oxford comma. func englishList(ss []string) string { switch n := len(ss); n { @@ -72,45 +100,6 @@ func firstNonEmptyString(ss ...string) string { return "" } -// newGitHubClient returns a new github.Client configured with an access token -// and a http client, if available. -func newGitHubClient(ctx context.Context, httpClient *http.Client) *github.Client { - for _, key := range []string{ - "CHEZMOI_GITHUB_ACCESS_TOKEN", - "GITHUB_ACCESS_TOKEN", - "GITHUB_TOKEN", - } { - if accessToken := os.Getenv(key); accessToken != "" { - httpClient = oauth2.NewClient( - context.WithValue(ctx, oauth2.HTTPClient, httpClient), - oauth2.StaticTokenSource(&oauth2.Token{ - AccessToken: accessToken, - })) - break - } - } - return github.NewClient(httpClient) -} - -// 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) - } -} - // pluralize returns the English plural form of singular. func pluralize(singular string) string { if strings.HasSuffix(singular, "y") { @@ -119,8 +108,17 @@ func pluralize(singular string) string { return singular + "s" } -// titleize returns s with its first rune titleized. -func titleize(s string) string { +// stringersToStrings converts a slice of fmt.Stringers to a list of strings. +func stringersToStrings[T fmt.Stringer](ss []T) []string { + result := make([]string, len(ss)) + for i, s := range ss { + result[i] = s.String() + } + return result +} + +// titleFirst returns s with its first rune converted to title case. +func titleFirst(s string) string { if s == "" { return s } @@ -135,63 +133,19 @@ func upperSnakeCaseToCamelCase(s string) string { for i, word := range words { if i == 0 { words[i] = strings.ToLower(word) - } else if !isWellKnownAbbreviation(word) { - words[i] = titleize(strings.ToLower(word)) + } else if !wellKnownAbbreviations.Contains(word) { + words[i] = titleFirst(strings.ToLower(word)) } } return strings.Join(words, "") } -// uniqueAbbreviations returns a map of unique abbreviations of values to -// values. Values always map to themselves. -func uniqueAbbreviations(values []string) map[string]string { - abbreviations := make(map[string][]string) - for _, value := range values { - for i := 1; i <= len(value); i++ { - abbreviation := value[:i] - abbreviations[abbreviation] = append(abbreviations[abbreviation], value) - } - } - uniqueAbbreviations := make(map[string]string) - for abbreviation, values := range abbreviations { - if len(values) == 1 { - uniqueAbbreviations[abbreviation] = values[0] - } - } - for _, value := range values { - uniqueAbbreviations[value] = value - } - return uniqueAbbreviations -} - -// upperSnakeCaseToCamelCaseKeys returns m with all keys converted from +// upperSnakeCaseToCamelCaseMap returns m with all keys converted from // UPPER_SNAKE_CASE to camelCase. -func upperSnakeCaseToCamelCaseMap(m map[string]interface{}) map[string]interface{} { - result := make(map[string]interface{}) +func upperSnakeCaseToCamelCaseMap[V any](m map[string]V) map[string]V { + result := make(map[string]V) 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/internal/cmd/util_go1.16_test.go b/internal/cmd/util_go1.16_test.go deleted file mode 100644 index 8a437e99960..00000000000 --- a/internal/cmd/util_go1.16_test.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build !go1.17 -// +build !go1.17 - -package cmd - -import ( - "os" - "testing" -) - -func testSetenv(t *testing.T, key, value string) { - t.Helper() - prevValue := os.Getenv(key) - t.Cleanup(func() { - os.Setenv(key, prevValue) - }) - os.Setenv(key, value) -} diff --git a/internal/cmd/util_go1.17_test.go b/internal/cmd/util_go1.17_test.go deleted file mode 100644 index e7f4edb89ca..00000000000 --- a/internal/cmd/util_go1.17_test.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build go1.17 -// +build go1.17 - -package cmd - -import "testing" - -func testSetenv(t *testing.T, key, value string) { - t.Helper() - t.Setenv(key, value) -} diff --git a/internal/cmd/util_test.go b/internal/cmd/util_test.go index 8e0379dd93e..7e9841edbf1 100644 --- a/internal/cmd/util_test.go +++ b/internal/cmd/util_test.go @@ -1,12 +1,40 @@ package cmd import ( - "strings" "testing" - "github.com/stretchr/testify/assert" + "github.com/alecthomas/assert/v2" ) +func TestCamelCaseToUpperSnakeCase(t *testing.T) { + for _, tc := range []struct { + s string + expected string + }{ + { + "", + "", + }, + { + "camel", + "CAMEL", + }, + { + "camelCase", + "CAMEL_CASE", + }, + { + "bugReportURL", + "BUG_REPORT_URL", + }, + } { + t.Run(tc.s, func(t *testing.T) { + actual := camelCaseToUpperSnakeCase(tc.s) + assert.Equal(t, tc.expected, actual) + }) + } +} + func TestEnglishList(t *testing.T) { for _, tc := range []struct { ss []string @@ -110,79 +138,12 @@ func TestEnglishListWithNoun(t *testing.T) { } } -func TestUniqueAbbreviations(t *testing.T) { - for _, tc := range []struct { - values []string - expected map[string]string - }{ - { - values: nil, - expected: map[string]string{}, - }, - { - values: []string{ - "yes", - "no", - "all", - "quit", - }, - expected: map[string]string{ - "y": "yes", - "ye": "yes", - "yes": "yes", - "n": "no", - "no": "no", - "a": "all", - "al": "all", - "all": "all", - "q": "quit", - "qu": "quit", - "qui": "quit", - "quit": "quit", - }, - }, - { - values: []string{ - "ale", - "all", - "abort", - }, - expected: map[string]string{ - "ale": "ale", - "all": "all", - "ab": "abort", - "abo": "abort", - "abor": "abort", - "abort": "abort", - }, - }, - { - values: []string{ - "no", - "now", - "nope", - }, - expected: map[string]string{ - "no": "no", - "now": "now", - "nop": "nope", - "nope": "nope", - }, - }, - } { - t.Run(strings.Join(tc.values, "_"), func(t *testing.T) { - actual := uniqueAbbreviations(tc.values) - assert.Equal(t, tc.expected, actual) - }) - } -} - func TestUpperSnakeCaseToCamelCaseMap(t *testing.T) { - actual := upperSnakeCaseToCamelCaseMap(map[string]interface{}{ + actual := upperSnakeCaseToCamelCaseMap(map[string]any{ "BUG_REPORT_URL": "", "ID": "", }) - assert.Equal(t, map[string]interface{}{ + assert.Equal(t, map[string]any{ "bugReportURL": "", "id": "", }, actual) diff --git a/internal/cmd/util_unix.go b/internal/cmd/util_unix.go index 1124ef2437d..f3548606d9d 100644 --- a/internal/cmd/util_unix.go +++ b/internal/cmd/util_unix.go @@ -1,19 +1,22 @@ -//go:build !windows -// +build !windows +//go:build unix package cmd import ( - "io" + "io/fs" + "syscall" "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) const defaultEditor = "vi" -var defaultInterpreters = make(map[string]*chezmoi.Interpreter) +var defaultInterpreters = make(map[string]chezmoi.Interpreter) -// enableVirtualTerminalProcessing does nothing. -func enableVirtualTerminalProcessing(w io.Writer) error { - return nil +func fileInfoUID(info fs.FileInfo) int { + return int(info.Sys().(*syscall.Stat_t).Uid) //nolint:forcetypeassert +} + +func windowsVersion() (map[string]any, error) { + return nil, nil } diff --git a/internal/cmd/util_windows.go b/internal/cmd/util_windows.go index 4db388573d8..fc8e46e0cce 100644 --- a/internal/cmd/util_windows.go +++ b/internal/cmd/util_windows.go @@ -1,21 +1,24 @@ package cmd import ( - "io" - "os" + "fmt" + "strings" - "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" "github.com/twpayne/chezmoi/v2/internal/chezmoi" ) const defaultEditor = "notepad.exe" -var defaultInterpreters = map[string]*chezmoi.Interpreter{ +var defaultInterpreters = map[string]chezmoi.Interpreter{ "bat": {}, "cmd": {}, "com": {}, "exe": {}, + "nu": { + Command: "nu", + }, "pl": { Command: "perl", }, @@ -24,23 +27,43 @@ var defaultInterpreters = map[string]*chezmoi.Interpreter{ Args: []string{"-NoLogo"}, }, "py": { - Command: "python", + Command: "python3", }, "rb": { Command: "ruby", }, } -// enableVirtualTerminalProcessing enables virtual terminal processing. See -// https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences. -func enableVirtualTerminalProcessing(w io.Writer) error { - file, ok := w.(*os.File) - if !ok { - return nil +func windowsVersion() (map[string]any, error) { + registryKey, err := registry.OpenKey( + registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, + registry.QUERY_VALUE, + ) + if err != nil { + return nil, fmt.Errorf("registry.OpenKey: %w", err) + } + windowsVersion := make(map[string]any) + for _, name := range []string{ + "CurrentBuild", + "CurrentVersion", + "DisplayVersion", + "EditionID", + "ProductName", + } { + if value, _, err := registryKey.GetStringValue(name); err == nil { + key := strings.ToLower(name[:1]) + name[1:] + windowsVersion[key] = value + } } - var dwMode uint32 - if err := windows.GetConsoleMode(windows.Handle(file.Fd()), &dwMode); err != nil { - return nil // Ignore error in the case that fd is not a terminal. + for _, name := range []string{ + "CurrentMajorVersionNumber", + "CurrentMinorVersionNumber", + } { + if value, _, err := registryKey.GetIntegerValue(name); err == nil { + key := strings.ToLower(name[:1]) + name[1:] + windowsVersion[key] = value + } } - return windows.SetConsoleMode(windows.Handle(file.Fd()), dwMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) + return windowsVersion, nil } diff --git a/internal/cmd/vaulttemplatefuncs.go b/internal/cmd/vaulttemplatefuncs.go index 9a2e16ed4d0..f298be0688f 100644 --- a/internal/cmd/vaulttemplatefuncs.go +++ b/internal/cmd/vaulttemplatefuncs.go @@ -2,37 +2,40 @@ package cmd import ( "encoding/json" - "fmt" + "os" "os/exec" + + "github.com/twpayne/chezmoi/v2/internal/chezmoilog" ) type vaultConfig struct { - Command string - cache map[string]interface{} + Command string `json:"command" mapstructure:"command" yaml:"command"` + cache map[string]any } -func (c *Config) vaultTemplateFunc(key string) interface{} { +func (c *Config) vaultTemplateFunc(key string) any { 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) + cmd := exec.Command(c.Vault.Command, args...) //nolint:gosec + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := chezmoilog.LogCmdOutput(c.logger, cmd) if err != nil { - returnTemplateError(fmt.Errorf("%s: %w\n%s", shellQuoteCommand(name, args), err, output)) - return nil + panic(newCmdOutputError(cmd, output, err)) } - var data interface{} + + var data any if err := json.Unmarshal(output, &data); err != nil { - returnTemplateError(fmt.Errorf("%s: %w\n%s", shellQuoteCommand(name, args), err, output)) - return nil + panic(newParseCmdOutputError(c.Vault.Command, args, output, err)) } + if c.Vault.cache == nil { - c.Vault.cache = make(map[string]interface{}) + c.Vault.cache = make(map[string]any) } c.Vault.cache[key] = data + return data } diff --git a/internal/cmd/verifycmd.go b/internal/cmd/verifycmd.go index 85e13bc226b..7543610cb80 100644 --- a/internal/cmd/verifycmd.go +++ b/internal/cmd/verifycmd.go @@ -7,7 +7,7 @@ import ( ) type verifyCmdConfig struct { - exclude *chezmoi.EntryTypeSet + Exclude *chezmoi.EntryTypeSet `json:"exclude" mapstructure:"exclude" yaml:"exclude"` include *chezmoi.EntryTypeSet init bool recursive bool @@ -15,37 +15,33 @@ type verifyCmdConfig struct { 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, - }, + Use: "verify [target]...", + Short: "Exit with success if the destination state matches the target state, fail otherwise", + Long: mustLongHelp("verify"), + Example: example("verify"), + ValidArgsFunction: c.targetValidArgs, + RunE: c.runVerifyCmd, + Annotations: newAnnotations( + persistentStateModeReadMockWrite, + requiresSourceDirectory, + ), } - flags := verifyCmd.Flags() - flags.VarP(c.verify.exclude, "exclude", "x", "Exclude entry types") - flags.VarP(c.verify.include, "include", "i", "Include entry types") - flags.BoolVar(&c.verify.init, "init", c.update.init, "Recreate config file from template") - flags.BoolVarP(&c.verify.recursive, "recursive", "r", c.verify.recursive, "Recurse into subdirectories") + verifyCmd.Flags().VarP(c.Verify.Exclude, "exclude", "x", "Exclude entry types") + verifyCmd.Flags().VarP(c.Verify.include, "include", "i", "Include entry types") + verifyCmd.Flags().BoolVar(&c.Verify.init, "init", c.Verify.init, "Recreate config file from template") + verifyCmd.Flags().BoolVarP(&c.Verify.recursive, "recursive", "r", c.Verify.recursive, "Recurse into subdirectories") return verifyCmd } func (c *Config) runVerifyCmd(cmd *cobra.Command, args []string) error { - dryRunSystem := chezmoi.NewDryRunSystem(c.destSystem) - if err := c.applyArgs(cmd.Context(), dryRunSystem, c.DestDirAbsPath, args, applyArgsOptions{ - include: c.verify.include.Sub(c.verify.exclude), - init: c.verify.init, - recursive: c.verify.recursive, + errorOnWriteSystem := chezmoi.NewErrorOnWriteSystem(c.destSystem, chezmoi.ExitCodeError(1)) + return c.applyArgs(cmd.Context(), errorOnWriteSystem, c.DestDirAbsPath, args, applyArgsOptions{ + cmd: cmd, + filter: chezmoi.NewEntryTypeFilter(c.Verify.include.Bits(), c.Verify.Exclude.Bits()), + init: c.Verify.init, + recursive: c.Verify.recursive, umask: c.Umask, - }); err != nil { - return err - } - if dryRunSystem.Modified() { - return ExitCodeError(1) - } - return nil + }) } diff --git a/internal/cmd/verifycmd_test.go b/internal/cmd/verifycmd_test.go index b1ded1daab6..a487711e490 100644 --- a/internal/cmd/verifycmd_test.go +++ b/internal/cmd/verifycmd_test.go @@ -1,11 +1,12 @@ package cmd import ( + "io/fs" "testing" - "github.com/stretchr/testify/assert" - "github.com/twpayne/go-vfs/v4" - "github.com/twpayne/go-vfs/v4/vfst" + "github.com/alecthomas/assert/v2" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-vfs/v5/vfst" "github.com/twpayne/chezmoi/v2/internal/chezmoitest" ) @@ -13,21 +14,21 @@ import ( func TestVerifyCmd(t *testing.T) { for _, tc := range []struct { name string - root interface{} + root any expectedErr error }{ { name: "empty", - root: map[string]interface{}{ - "/home/user": &vfst.Dir{ - Perm: 0o700, + root: map[string]any{ + "/home/user/.local/share/chezmoi": &vfst.Dir{ + Perm: fs.ModePerm &^ chezmoitest.Umask, }, }, }, { name: "file", - root: map[string]interface{}{ - "/home/user": map[string]interface{}{ + root: map[string]any{ + "/home/user": map[string]any{ ".bashrc": &vfst.File{ Contents: []byte("# contents of .bashrc\n"), Perm: 0o666 &^ chezmoitest.Umask, diff --git a/internal/cmds/execute-template/main.go b/internal/cmds/execute-template/main.go new file mode 100644 index 00000000000..4c01cf91132 --- /dev/null +++ b/internal/cmds/execute-template/main.go @@ -0,0 +1,156 @@ +package main + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "io/fs" + "net/http" + "os" + "os/exec" + "path" + "regexp" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/google/go-github/v63/github" + "github.com/google/renameio/v2/maybe" + "gopkg.in/yaml.v3" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +var ( + templateDataFilename = flag.String("data", "", "data filename") + outputFilename = flag.String("output", "", "output filename") +) + +type gitHubClient struct { + ctx context.Context //nolint:containedctx + client *github.Client +} + +func newGitHubClient(ctx context.Context) *gitHubClient { + return &gitHubClient{ + ctx: ctx, + client: chezmoi.NewGitHubClient(ctx, http.DefaultClient), + } +} + +func (c *gitHubClient) gitHubListReleases(ownerRepo string) []*github.RepositoryRelease { + owner, repo, ok := strings.Cut(ownerRepo, "/") + if !ok { + panic(fmt.Errorf("%s: not a owner/repo", ownerRepo)) + } + + var allRepositoryReleases []*github.RepositoryRelease + opts := &github.ListOptions{ + PerPage: 100, + } + for { + repositoryReleases, resp, err := c.client.Repositories.ListReleases(c.ctx, owner, repo, opts) + if err != nil { + panic(err) + } + allRepositoryReleases = append(allRepositoryReleases, repositoryReleases...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allRepositoryReleases +} + +func (c *gitHubClient) gitHubLatestRelease(ownerRepo string) *github.RepositoryRelease { + owner, repo, ok := strings.Cut(ownerRepo, "/") + if !ok { + panic(fmt.Errorf("%s: not a owner/repo", ownerRepo)) + } + + rr, _, err := c.client.Repositories.GetLatestRelease(c.ctx, owner, repo) + if err != nil { + panic(err) + } + + return rr +} + +func run() error { + flag.Parse() + + var templateData any + if *templateDataFilename != "" { + dataBytes, err := os.ReadFile(*templateDataFilename) + if err != nil { + return err + } + if err := yaml.Unmarshal(dataBytes, &templateData); err != nil { + return err + } + } + + if flag.NArg() == 0 { + return errors.New("no arguments") + } + + templateName := path.Base(flag.Arg(0)) + buffer := &bytes.Buffer{} + funcMap := sprig.TxtFuncMap() + gitHubClient := newGitHubClient(context.Background()) + funcMap["exists"] = func(name string) bool { + switch _, err := os.Stat(name); { + case err == nil: + return true + case errors.Is(err, fs.ErrNotExist): + return false + default: + panic(err) + } + } + funcMap["gitHubLatestRelease"] = gitHubClient.gitHubLatestRelease + funcMap["gitHubListReleases"] = gitHubClient.gitHubListReleases + funcMap["gitHubTimestampFormat"] = func(layout string, timestamp github.Timestamp) string { + return timestamp.Format(layout) + } + funcMap["output"] = func(name string, args ...string) string { + cmd := exec.Command(name, args...) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + panic(err) + } + return string(out) + } + funcMap["replaceAllRegex"] = func(expr, repl, s string) string { + return regexp.MustCompile(expr).ReplaceAllString(s, repl) + } + tmpl, err := template.New(templateName).Funcs(funcMap).ParseFiles(flag.Args()...) + if err != nil { + return err + } + if err := tmpl.Execute(buffer, templateData); err != nil { + return err + } + + if *outputFilename == "" { + if _, err := os.Stdout.Write(buffer.Bytes()); err != nil { + return err + } + } else if data, err := os.ReadFile(*outputFilename); err != nil || !bytes.Equal(data, buffer.Bytes()) { + if err := maybe.WriteFile(*outputFilename, buffer.Bytes(), 0o644); err != nil { + return err + } + } + + return nil +} + +func main() { + if err := run(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/internal/cmds/generate-chezmoi.io-content-docs/main.go b/internal/cmds/generate-chezmoi.io-content-docs/main.go deleted file mode 100644 index 33cd2cde56d..00000000000 --- a/internal/cmds/generate-chezmoi.io-content-docs/main.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "bufio" - "flag" - "fmt" - "log" - "os" - "path" - "regexp" - "strings" -) - -var ( - debug = flag.Bool("debug", false, "debug") - shortTitle = flag.String("shorttitle", "", "short title") - longTitle = flag.String("longtitle", "", "long title") - - replaceURLRegexp = regexp.MustCompile(`https://github\.com/twpayne/chezmoi/blob/master/docs/[A-Z]+\.md`) - nonStandardPageRenames = map[string]string{ - "HOWTO": "how-to", - "QUICKSTART": "quick-start", - } -) - -func run() error { - flag.Parse() - - fmt.Printf(""+ - "---\n"+ - "title: %q\n"+ - "---\n"+ - "\n", - *shortTitle, - ) - - s := bufio.NewScanner(os.Stdin) - state := "replace-title" - for s.Scan() { - if *debug { - log.Printf("%s: %q", state, s.Text()) - } - switch state { - case "replace-title": - fmt.Printf("# %s\n\n", *longTitle) - state = "find-toc" - case "find-toc": - if s.Text() == "" { - state = "skip-toc" - } - case "skip-toc": - if s.Text() == "" { - state = "copy-content" - } - case "copy-content": - text := s.Text() - text = replaceURLRegexp.ReplaceAllStringFunc(text, func(s string) string { - name := path.Base(s) - name = strings.TrimSuffix(name, path.Ext(name)) - var ok bool - newName, ok := nonStandardPageRenames[name] - if !ok { - newName = strings.ToLower(name) - } - return "/docs/" + newName + "/" - }) - fmt.Println(text) - } - } - return s.Err() -} - -func main() { - if err := run(); err != nil { - fmt.Println(err) - os.Exit(1) - } -} diff --git a/internal/cmds/generate-install.sh/install.sh.tmpl b/internal/cmds/generate-install.sh/install.sh.tmpl index 872df8c8a6e..406639f9080 100644 --- a/internal/cmds/generate-install.sh/install.sh.tmpl +++ b/internal/cmds/generate-install.sh/install.sh.tmpl @@ -7,38 +7,40 @@ set -e -BINDIR=${BINDIR:-./bin} +BINDIR="${BINDIR:-{{ .BinDir }}}" +CHEZMOI_USER_REPO="${CHEZMOI_USER_REPO:-twpayne/chezmoi}" TAGARG=latest LOG_LEVEL=2 -EXECARGS= -GITHUB_DOWNLOAD=https://github.com/twpayne/chezmoi/releases/download +GITHUB_DOWNLOAD="https://github.com/${CHEZMOI_USER_REPO}/releases/download" -tmpdir=$(mktemp -d) -trap 'rm -rf ${tmpdir}' EXIT +tmpdir="$(mktemp -d)" +trap 'rm -rf -- "${tmpdir}"' EXIT +trap 'exit' INT TERM usage() { - this="$1" + this="${1}" cat <&2 + printf '%s: unsupported platform\n' "${1}" 1>&2 return 1 ;; esac @@ -158,12 +161,12 @@ check_goos_goarch() { get_libc() { if is_command ldd; then case "$(ldd --version 2>&1 | tr '[:upper:]' '[:lower:]')" in - *glibc*|"*gnu libc*") - echo glibc + *glibc* | *"gnu libc"*) + printf glibc return ;; *musl*) - echo musl + printf musl return ;; esac @@ -171,7 +174,7 @@ get_libc() { if is_command getconf; then case "$(getconf GNU_LIBC_VERSION 2>&1)" in *glibc*) - echo glibc + printf glibc return ;; esac @@ -181,40 +184,42 @@ get_libc() { } real_tag() { - tag=$1 + tag="${1}" log_debug "checking GitHub for tag ${tag}" - release_url="https://github.com/twpayne/chezmoi/releases/${tag}" - json=$(http_get "${release_url}" "Accept: application/json") + release_url="https://github.com/${CHEZMOI_USER_REPO}/releases/${tag}" + json="$(http_get "${release_url}" "Accept: application/json")" if [ -z "${json}" ]; then log_err "real_tag error retrieving GitHub release ${tag}" return 1 fi - real_tag=$(echo "${json}" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//') + real_tag="$(printf '%s\n' "${json}" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//')" if [ -z "${real_tag}" ]; then log_err "real_tag error determining real tag of GitHub release ${tag}" return 1 fi - test -z "${real_tag}" && return 1 + if [ -z "${real_tag}" ]; then + return 1 + fi log_debug "found tag ${real_tag} for ${tag}" - echo "${real_tag}" + printf '%s' "${real_tag}" } http_get() { - tmpfile=$(mktemp) - http_download "${tmpfile}" "$1" "$2" || return 1 - body=$(cat "${tmpfile}") + tmpfile="$(mktemp)" + http_download "${tmpfile}" "${1}" "${2}" || return 1 + body="$(cat "${tmpfile}")" rm -f "${tmpfile}" - echo "${body}" + printf '%s\n' "${body}" } http_download_curl() { - local_file=$1 - source_url=$2 - header=$3 + local_file="${1}" + source_url="${2}" + header="${3}" if [ -z "${header}" ]; then - code=$(curl -w '%{http_code}' -sL -o "${local_file}" "${source_url}") + code="$(curl -w '%{http_code}' -sL -o "${local_file}" "${source_url}")" else - code=$(curl -w '%{http_code}' -sL -H "${header}" -o "${local_file}" "${source_url}") + code="$(curl -w '%{http_code}' -sL -H "${header}" -o "${local_file}" "${source_url}")" fi if [ "${code}" != "200" ]; then log_debug "http_download_curl received HTTP status ${code}" @@ -224,9 +229,9 @@ http_download_curl() { } http_download_wget() { - local_file=$1 - source_url=$2 - header=$3 + local_file="${1}" + source_url="${2}" + header="${3}" if [ -z "${header}" ]; then wget -q -O "${local_file}" "${source_url}" || return 1 else @@ -235,12 +240,12 @@ http_download_wget() { } http_download() { - log_debug "http_download $2" + log_debug "http_download ${2}" if is_command curl; then - http_download_curl "$@" || return 1 + http_download_curl "${@}" || return 1 return elif is_command wget; then - http_download_wget "$@" || return 1 + http_download_wget "${@}" || return 1 return fi log_crit "http_download unable to find wget or curl" @@ -248,19 +253,19 @@ http_download() { } hash_sha256() { - target=$1 + target="${1}" if is_command sha256sum; then - hash=$(sha256sum "${target}") || return 1 - echo "${hash}" | cut -d ' ' -f 1 + hash="$(sha256sum "${target}")" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f 1 elif is_command shasum; then - hash=$(shasum -a 256 "${target}" 2>/dev/null) || return 1 - echo "${hash}" | cut -d ' ' -f 1 + hash="$(shasum -a 256 "${target}" 2>/dev/null)" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f 1 elif is_command sha256; then - hash=$(sha256 -q "${target}" 2>/dev/null) || return 1 - echo "${hash}" | cut -d ' ' -f 1 + hash="$(sha256 -q "${target}" 2>/dev/null)" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f 1 elif is_command openssl; then - hash=$(openssl dgst -sha256 "${target}") || return 1 - echo "${hash}" | cut -d ' ' -f a + hash="$(openssl dgst -sha256 "${target}")" || return 1 + printf '%s' "${hash}" | cut -d ' ' -f a else log_crit "hash_sha256 unable to find command to compute SHA256 hash" return 1 @@ -268,17 +273,17 @@ hash_sha256() { } hash_sha256_verify() { - target=$1 - checksums=$2 - basename=${target##*/} + target="${1}" + checksums="${2}" + basename="${target##*/}" - want=$(grep "${basename}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) + want="$(grep "${basename}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1)" if [ -z "${want}" ]; then log_err "hash_sha256_verify unable to find checksum for ${target} in ${checksums}" return 1 fi - got=$(hash_sha256 "${target}") + got="$(hash_sha256 "${target}")" if [ "${want}" != "${got}" ]; then log_err "hash_sha256_verify checksum for ${target} did not verify ${want} vs ${got}" return 1 @@ -286,11 +291,11 @@ hash_sha256_verify() { } untar() { - tarball=$1 + tarball="${1}" case "${tarball}" in *.tar.gz | *.tgz) tar -xzf "${tarball}" ;; *.tar) tar -xf "${tarball}" ;; - *.zip) unzip "${tarball}" ;; + *.zip) unzip -- "${tarball}" ;; *) log_err "untar unknown archive format for ${tarball}" return 1 @@ -299,27 +304,27 @@ untar() { } is_command() { - command -v "$1" >/dev/null + type "${1}" >/dev/null 2>&1 } log_debug() { [ 3 -le "${LOG_LEVEL}" ] || return 0 - echo debug "$@" 1>&2 + printf 'debug %s\n' "${*}" 1>&2 } log_info() { [ 2 -le "${LOG_LEVEL}" ] || return 0 - echo info "$@" 1>&2 + printf 'info %s\n' "${*}" 1>&2 } log_err() { [ 1 -le "${LOG_LEVEL}" ] || return 0 - echo error "$@" 1>&2 + printf 'error %s\n' "${*}" 1>&2 } log_crit() { [ 0 -le "${LOG_LEVEL}" ] || return 0 - echo critical "$@" 1>&2 + printf 'critical %s\n' "${*}" 1>&2 } -main "$@" +main "${@}" diff --git a/internal/cmds/generate-install.sh/main.go b/internal/cmds/generate-install.sh/main.go index 41e932bfa32..99683184af1 100644 --- a/internal/cmds/generate-install.sh/main.go +++ b/internal/cmds/generate-install.sh/main.go @@ -9,10 +9,15 @@ import ( "sort" "text/template" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" + + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" ) -var output = flag.String("o", "", "output") +var ( + binDir = flag.String("b", "./bin", "binary directory") + output = flag.String("o", "", "output") +) type platform struct { GOOS string @@ -38,7 +43,9 @@ type platformValue struct { type platformSet map[platform]platformValue func goToolDistList() (platformSet, error) { - data, err := exec.Command("go", "tool", "dist", "list", "-json").Output() + cmd := exec.Command("go", "tool", "dist", "list", "-json") + cmd.Stderr = os.Stderr + data, err := cmd.Output() if err != nil { return nil, err } @@ -65,10 +72,10 @@ func run() error { } var goreleaserConfig struct { Builds []struct { - GOOS []string - GOARCH []string - Ignore []platform - } + GOOS []string `yaml:"goos"` + GOARCH []string `yaml:"goarch"` + Ignore []platform `yaml:"ignore"` + } `yaml:"builds"` } if err := yaml.Unmarshal(data, &goreleaserConfig); err != nil { return err @@ -84,30 +91,23 @@ func run() error { delete(supportedPlatforms, newPlatform("windows", "arm64")) // Build set of platforms. - allPlatforms := make(map[platform]struct{}) + allPlatforms := chezmoiset.New[platform]() for _, build := range goreleaserConfig.Builds { - buildPlatforms := make(map[platform]struct{}) + buildPlatforms := chezmoiset.New[platform]() for _, goos := range build.GOOS { for _, goarch := range build.GOARCH { platform := newPlatform(goos, goarch) if _, ok := supportedPlatforms[platform]; ok { - buildPlatforms[platform] = struct{}{} + buildPlatforms.Add(platform) } } } - for _, ignore := range build.Ignore { - delete(buildPlatforms, ignore) - } - for platform := range buildPlatforms { - allPlatforms[platform] = struct{}{} - } + buildPlatforms.Remove(build.Ignore...) + allPlatforms.AddSet(buildPlatforms) } // Sort platforms. - sortedPlatforms := make([]platform, 0, len(allPlatforms)) - for platform := range allPlatforms { - sortedPlatforms = append(sortedPlatforms, platform) - } + sortedPlatforms := allPlatforms.Elements() sort.Slice(sortedPlatforms, func(i, j int) bool { return sortedPlatforms[i].String() < sortedPlatforms[j].String() }) @@ -128,8 +128,10 @@ func run() error { defer outputFile.Close() } return installShTemplate.ExecuteTemplate(outputFile, "install.sh.tmpl", struct { + BinDir string Platforms []platform }{ + BinDir: *binDir, Platforms: sortedPlatforms, }) } diff --git a/internal/cmds/generate-install.sh/main_test.go b/internal/cmds/generate-install.sh/main_test.go new file mode 100644 index 00000000000..5a1ad5d2abc --- /dev/null +++ b/internal/cmds/generate-install.sh/main_test.go @@ -0,0 +1,12 @@ +package main + +import ( + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestGoToolDistList(t *testing.T) { + _, err := goToolDistList() + assert.NoError(t, err) +} diff --git a/internal/cmds/lint-commit-messages/main.go b/internal/cmds/lint-commit-messages/main.go new file mode 100644 index 00000000000..ebc532d3706 --- /dev/null +++ b/internal/cmds/lint-commit-messages/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "regexp" + "strings" +) + +var commitRx = regexp.MustCompile(`\A([0-9a-f]{40}) (chore(?:\([\w\-]+\))?|docs|feat|fix): `) + +func run() error { + args := append([]string{"log", "--format=oneline"}, os.Args[1:]...) + cmd := exec.Command("git", args...) + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("git: %w", err) + } + + var invalidCommitMessages []string + scanner := bufio.NewScanner(bytes.NewReader(output)) + for scanner.Scan() { + commitMessage := scanner.Text() + if !commitRx.MatchString(commitMessage) { + invalidCommitMessages = append(invalidCommitMessages, commitMessage) + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("scanner: %w", err) + } + + if len(invalidCommitMessages) != 0 { + return fmt.Errorf("invalid commit messages:\n%s", strings.Join(invalidCommitMessages, "\n")) + } + + return nil +} + +func main() { + if err := run(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/internal/cmds/lint-commit-messages/main_test.go b/internal/cmds/lint-commit-messages/main_test.go new file mode 100644 index 00000000000..7aaa33c7715 --- /dev/null +++ b/internal/cmds/lint-commit-messages/main_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "strings" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestCommitRx(t *testing.T) { + prefix := strings.Repeat("0", 40) + " " + for s, match := range map[string]bool{ + "chore(deps): text": true, + "chore(deps-dev): text": true, + "chore: text": true, + "docs: text": true, + "feat: text": true, + "fix: text": true, + "fixup!": false, + "snapshot": false, + } { + assert.Equal(t, match, commitRx.MatchString(prefix+s)) + } +} diff --git a/internal/cmds/lint-txtar/main.go b/internal/cmds/lint-txtar/main.go new file mode 100644 index 00000000000..bc2a0a2d8aa --- /dev/null +++ b/internal/cmds/lint-txtar/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "slices" + "strings" + + "github.com/rogpeppe/go-internal/txtar" + + "github.com/twpayne/chezmoi/v2/internal/chezmoierrors" + "github.com/twpayne/chezmoi/v2/internal/chezmoiset" +) + +var write = flag.Bool("w", false, "rewrite archives") + +func lintFilenames(archiveFilename string, archive *txtar.Archive) error { + var errs []error + filenames := chezmoiset.New[string]() + for _, file := range archive.Files { + if file.Name == "" { + errs = append(errs, fmt.Errorf("%s: empty filename", archiveFilename)) + } else { + if filenames.Contains(file.Name) { + errs = append(errs, fmt.Errorf("%s: %s: duplicate filename", archiveFilename, file.Name)) + } + filenames.Add(file.Name) + } + } + return errors.Join(errs...) +} + +func sortFilesFunc(file1, file2 txtar.File) int { + fileComponents1 := strings.Split(file1.Name, "/") + fileComponents2 := strings.Split(file2.Name, "/") + return slices.Compare(fileComponents1, fileComponents2) +} + +func tidyTxtar(archiveFilename string) error { + archive, err := txtar.ParseFile(archiveFilename) + if err != nil { + return err + } + + if err := lintFilenames(archiveFilename, archive); err != nil { + return err + } + + if slices.IsSortedFunc(archive.Files, sortFilesFunc) { + return nil + } + + if *write { + slices.SortFunc(archive.Files, sortFilesFunc) + return os.WriteFile(archiveFilename, txtar.Format(archive), 0o666) + } + + return fmt.Errorf("%s: files are not sorted", archiveFilename) +} + +func run() error { + flag.Parse() + + errs := make([]error, 0, flag.NArg()) + for _, arg := range flag.Args() { + errs = append(errs, tidyTxtar(arg)) + } + return chezmoierrors.Combine(errs...) +} + +func main() { + if err := run(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/internal/cmds/lint-whitespace/main.go b/internal/cmds/lint-whitespace/main.go index 8b387883b28..ef530472f0c 100644 --- a/internal/cmds/lint-whitespace/main.go +++ b/internal/cmds/lint-whitespace/main.go @@ -9,20 +9,20 @@ import ( "regexp" "strings" - "go.uber.org/multierr" + "github.com/twpayne/chezmoi/v2/internal/chezmoierrors" ) var ( ignoreRxs = []*regexp.Regexp{ regexp.MustCompile(`\.svg\z`), - regexp.MustCompile(`\A\.devcontainer/library-scripts\z`), regexp.MustCompile(`\A\.git\z`), regexp.MustCompile(`\A\.idea\z`), regexp.MustCompile(`\A\.vagrant\z`), - regexp.MustCompile(`\A\.vscode/settings\.json\z`), - regexp.MustCompile(`\Aassets/chezmoi\.io/public\z`), - regexp.MustCompile(`\Aassets/chezmoi\.io/resources\z`), - regexp.MustCompile(`\Aassets/chezmoi\.io/themes/book\z`), + regexp.MustCompile(`\A\.venv\z`), + regexp.MustCompile(`\A\.vscode\z`), + regexp.MustCompile(`\ACOMMIT\z`), + regexp.MustCompile(`\Aassets/chezmoi\.io/\.venv\z`), + regexp.MustCompile(`\Aassets/chezmoi\.io/site\z`), regexp.MustCompile(`\Aassets/scripts/install\.ps1\z`), regexp.MustCompile(`\Acompletions/chezmoi\.ps1\z`), regexp.MustCompile(`\Adist\z`), @@ -31,36 +31,41 @@ var ( trailingWhitespaceRx = regexp.MustCompile(`\s+\z`) ) -func lintFile(filename string) error { - data, err := os.ReadFile(filename) - if err != nil { - return err - } - +func lintData(filename string, data []byte) error { if !strings.HasPrefix(http.DetectContentType(data), "text/") { return nil } lines := bytes.Split(data, []byte{'\n'}) + var errs []error + for i, line := range lines { switch { case crlfLineEndingRx.Match(line): - err = multierr.Append(err, fmt.Errorf("::error file=%s,line=%d::CRLF line ending", filename, i+1)) + errs = append(errs, fmt.Errorf("::error file=%s,line=%d::CRLF line ending", filename, i+1)) case trailingWhitespaceRx.Match(line): - err = multierr.Append(err, fmt.Errorf("::error file=%s,line=%d::trailing whitespace", filename, i+1)) + errs = append(errs, fmt.Errorf("::error file=%s,line=%d::trailing whitespace", filename, i+1)) } } if len(data) > 0 && len(lines[len(lines)-1]) != 0 { - err = multierr.Append(err, fmt.Errorf("::error file=%s,line=%d::no newline at end of file", filename, len(lines)+1)) + errs = append(errs, fmt.Errorf("::error file=%s,line=%d::no newline at end of file", filename, len(lines)+1)) } - return err + return chezmoierrors.Combine(errs...) +} + +func lintFile(filename string) error { + data, err := os.ReadFile(filename) + if err != nil { + return err + } + return lintData(filename, data) } func run() error { - var lintErrs error + var lintErrs []error if err := fs.WalkDir(os.DirFS("."), ".", func(path string, dirEntry fs.DirEntry, err error) error { if err != nil { return err @@ -74,20 +79,18 @@ func run() error { } } if dirEntry.Type().IsRegular() { - lintErrs = multierr.Append(lintErrs, lintFile(path)) + lintErrs = append(lintErrs, lintFile(path)) } return nil }); err != nil { return err } - return lintErrs + return chezmoierrors.Combine(lintErrs...) } func main() { if err := run(); err != nil { - for _, e := range multierr.Errors(err) { - fmt.Println(e) - } + fmt.Println(err) os.Exit(1) } } diff --git a/internal/cmds/lint-whitespace/main_test.go b/internal/cmds/lint-whitespace/main_test.go new file mode 100644 index 00000000000..491eb965a12 --- /dev/null +++ b/internal/cmds/lint-whitespace/main_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "strconv" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestLintData(t *testing.T) { + for i, tc := range []struct { + data []byte + expectedErrStr string + }{ + { + data: nil, + expectedErrStr: "", + }, + { + data: []byte("package main\n"), + expectedErrStr: "", + }, + { + data: []byte("package main\r\n"), + expectedErrStr: "CRLF line ending", + }, + { + data: []byte("package main \n"), + expectedErrStr: "trailing whitespace", + }, + { + data: []byte("package main"), + expectedErrStr: "no newline at end of file", + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + actualErr := lintData("main.go", tc.data) + if tc.expectedErrStr == "" { + assert.NoError(t, actualErr) + } else { + assert.Contains(t, actualErr.Error(), tc.expectedErrStr) + } + }) + } +} diff --git a/internal/git/git.go b/internal/git/git.go deleted file mode 100644 index 09311b6df94..00000000000 --- a/internal/git/git.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package git contains functions for interacting with git. -package git diff --git a/logo-144px.svg b/logo-144px.svg deleted file mode 100644 index 1f97a79a04d..00000000000 --- a/logo-144px.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/main.go b/main.go index 12460eb82b4..0994a462c96 100644 --- a/main.go +++ b/main.go @@ -1,17 +1,17 @@ -//go:build go1.16 -// +build go1.16 - //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 //go:generate go run ./internal/cmds/generate-install.sh -o assets/scripts/install.sh +//go:generate go run ./internal/cmds/generate-install.sh -b .local/bin -o assets/scripts/install-local-bin.sh package main import ( "os" + _ "golang.org/x/crypto/x509roots/fallback" // Embed fallback X.509 trusted roots + "github.com/twpayne/chezmoi/v2/internal/cmd" ) diff --git a/main_test.go b/main_test.go new file mode 100644 index 00000000000..ff960cd18ca --- /dev/null +++ b/main_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/twpayne/chezmoi/v2/internal/cmd" +) + +func TestMain(t *testing.T) { + versionInfo := cmd.VersionInfo{ + Version: version, + Commit: commit, + Date: date, + BuiltBy: builtBy, + } + args := []string{"--version"} + assert.Equal(t, 0, cmd.Main(versionInfo, args)) +}