From cfb91d4d0d85481b6ca1ad0a7bae6947898b7523 Mon Sep 17 00:00:00 2001 From: Tom Payne Date: Fri, 8 May 2020 00:08:19 +0100 Subject: [PATCH 1/4] snapshot --- .golangci.yml | 2 +- TODO | 9 + go.mod | 2 + go.sum | 30 +- v2/internal/chezmoi/attributes.go | 177 +++++ v2/internal/chezmoi/attributes_test.go | 98 +++ v2/internal/chezmoi/autotemplate.go | 90 +++ v2/internal/chezmoi/autotemplate_test.go | 168 +++++ v2/internal/chezmoi/boltpersistentstate.go | 121 +++ .../chezmoi/boltpersistentstate_test.go | 120 +++ v2/internal/chezmoi/canarysystem.go | 120 +++ v2/internal/chezmoi/canarysystem_test.go | 3 + v2/internal/chezmoi/chezmoi.go | 67 ++ v2/internal/chezmoi/datasystem.go | 118 +++ v2/internal/chezmoi/datasystem_test.go | 83 ++ v2/internal/chezmoi/debugsystem.go | 199 +++++ v2/internal/chezmoi/debugsystem_test.go | 3 + v2/internal/chezmoi/deststateentry.go | 120 +++ v2/internal/chezmoi/dryrunsystem.go | 104 +++ v2/internal/chezmoi/dryrunsystem_test.go | 3 + v2/internal/chezmoi/encryptiontool.go | 12 + v2/internal/chezmoi/encryptiontool_test.go | 145 ++++ v2/internal/chezmoi/errors.go | 25 + v2/internal/chezmoi/gitdiffsystem.go | 339 +++++++++ v2/internal/chezmoi/gitdiffsystem_test.go | 11 + v2/internal/chezmoi/gpgencryptiontool.go | 133 ++++ v2/internal/chezmoi/gpgencryptiontool_test.go | 49 ++ .../chezmoi/jsonserializationformat.go | 33 + .../chezmoi/jsonserializationformat_test.go | 3 + v2/internal/chezmoi/lazy.go | 83 ++ v2/internal/chezmoi/maybeshellquote.go | 61 ++ v2/internal/chezmoi/maybeshellquote_test.go | 26 + v2/internal/chezmoi/nullencryptiontool.go | 25 + .../chezmoi/nullencryptiontool_test.go | 3 + v2/internal/chezmoi/nullpersistentstate.go | 7 + v2/internal/chezmoi/nullsystem.go | 26 + v2/internal/chezmoi/nullsystem_test.go | 3 + v2/internal/chezmoi/patternset.go | 52 ++ v2/internal/chezmoi/patternset_test.go | 88 +++ v2/internal/chezmoi/persistentstate.go | 21 + v2/internal/chezmoi/persistentstate_test.go | 34 + v2/internal/chezmoi/realsystem.go | 140 ++++ v2/internal/chezmoi/realsystem_posix.go | 56 ++ v2/internal/chezmoi/realsystem_test.go | 68 ++ v2/internal/chezmoi/realsystem_windows.go | 8 + v2/internal/chezmoi/serializationformat.go | 8 + v2/internal/chezmoi/sourcestate.go | 545 ++++++++++++++ v2/internal/chezmoi/sourcestate_test.go | 707 ++++++++++++++++++ v2/internal/chezmoi/sourcestateentry.go | 79 ++ v2/internal/chezmoi/stringset.go | 33 + v2/internal/chezmoi/system.go | 32 + v2/internal/chezmoi/targetstateentry.go | 287 +++++++ v2/internal/chezmoi/targetstatentry_test.go | 174 +++++ v2/internal/chezmoi/tarsystem.go | 114 +++ v2/internal/chezmoi/tarsystem_test.go | 96 +++ .../chezmoi/tomlserializationformat.go | 24 + .../chezmoi/tomlserializationformat_test.go | 3 + .../chezmoi/yamlserializationformat.go | 24 + .../chezmoi/yamlserializationformat_test.go | 3 + 59 files changed, 5208 insertions(+), 9 deletions(-) create mode 100644 TODO create mode 100644 v2/internal/chezmoi/attributes.go create mode 100644 v2/internal/chezmoi/attributes_test.go create mode 100644 v2/internal/chezmoi/autotemplate.go create mode 100644 v2/internal/chezmoi/autotemplate_test.go create mode 100644 v2/internal/chezmoi/boltpersistentstate.go create mode 100644 v2/internal/chezmoi/boltpersistentstate_test.go create mode 100644 v2/internal/chezmoi/canarysystem.go create mode 100644 v2/internal/chezmoi/canarysystem_test.go create mode 100644 v2/internal/chezmoi/chezmoi.go create mode 100644 v2/internal/chezmoi/datasystem.go create mode 100644 v2/internal/chezmoi/datasystem_test.go create mode 100644 v2/internal/chezmoi/debugsystem.go create mode 100644 v2/internal/chezmoi/debugsystem_test.go create mode 100644 v2/internal/chezmoi/deststateentry.go create mode 100644 v2/internal/chezmoi/dryrunsystem.go create mode 100644 v2/internal/chezmoi/dryrunsystem_test.go create mode 100644 v2/internal/chezmoi/encryptiontool.go create mode 100644 v2/internal/chezmoi/encryptiontool_test.go create mode 100644 v2/internal/chezmoi/errors.go create mode 100644 v2/internal/chezmoi/gitdiffsystem.go create mode 100644 v2/internal/chezmoi/gitdiffsystem_test.go create mode 100644 v2/internal/chezmoi/gpgencryptiontool.go create mode 100644 v2/internal/chezmoi/gpgencryptiontool_test.go create mode 100644 v2/internal/chezmoi/jsonserializationformat.go create mode 100644 v2/internal/chezmoi/jsonserializationformat_test.go create mode 100644 v2/internal/chezmoi/lazy.go create mode 100644 v2/internal/chezmoi/maybeshellquote.go create mode 100644 v2/internal/chezmoi/maybeshellquote_test.go create mode 100644 v2/internal/chezmoi/nullencryptiontool.go create mode 100644 v2/internal/chezmoi/nullencryptiontool_test.go create mode 100644 v2/internal/chezmoi/nullpersistentstate.go create mode 100644 v2/internal/chezmoi/nullsystem.go create mode 100644 v2/internal/chezmoi/nullsystem_test.go create mode 100644 v2/internal/chezmoi/patternset.go create mode 100644 v2/internal/chezmoi/patternset_test.go create mode 100644 v2/internal/chezmoi/persistentstate.go create mode 100644 v2/internal/chezmoi/persistentstate_test.go create mode 100644 v2/internal/chezmoi/realsystem.go create mode 100644 v2/internal/chezmoi/realsystem_posix.go create mode 100644 v2/internal/chezmoi/realsystem_test.go create mode 100644 v2/internal/chezmoi/realsystem_windows.go create mode 100644 v2/internal/chezmoi/serializationformat.go create mode 100644 v2/internal/chezmoi/sourcestate.go create mode 100644 v2/internal/chezmoi/sourcestate_test.go create mode 100644 v2/internal/chezmoi/sourcestateentry.go create mode 100644 v2/internal/chezmoi/stringset.go create mode 100644 v2/internal/chezmoi/system.go create mode 100644 v2/internal/chezmoi/targetstateentry.go create mode 100644 v2/internal/chezmoi/targetstatentry_test.go create mode 100644 v2/internal/chezmoi/tarsystem.go create mode 100644 v2/internal/chezmoi/tarsystem_test.go create mode 100644 v2/internal/chezmoi/tomlserializationformat.go create mode 100644 v2/internal/chezmoi/tomlserializationformat_test.go create mode 100644 v2/internal/chezmoi/yamlserializationformat.go create mode 100644 v2/internal/chezmoi/yamlserializationformat_test.go diff --git a/.golangci.yml b/.golangci.yml index 891d2e9b7577..7ce5b3891a87 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,7 +19,6 @@ linters: - ineffassign - interfacer - misspell - - nakedret - prealloc - rowserrcheck - scopelint @@ -42,6 +41,7 @@ linters: - gomnd - lll - maligned + - nakedret - nestif - testpackage - wsl diff --git a/TODO b/TODO new file mode 100644 index 000000000000..5b742704d18e --- /dev/null +++ b/TODO @@ -0,0 +1,9 @@ +Use build flag to selectively activate v2 functionality? +Add dir to RunScript? +Encryption should use System.IdempotentCommandOutput +Encryption +Test EncryptionTool that uses randomly generated key + +script name is currently part of run once state (in key) +need to decide whether rename of script means its a different script +also executedAt in JSON representation should probably be runAt diff --git a/go.mod b/go.mod index af06c906e638..d3e5ef072f1f 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/mapstructure v1.2.2 // indirect github.com/mitchellh/reflectwalk v1.0.1 // indirect + github.com/muesli/combinator v0.3.0 github.com/pelletier/go-toml v1.7.0 github.com/pkg/diff v0.0.0-20190930165518-531926345625 github.com/rogpeppe/go-internal v1.6.0 @@ -45,6 +46,7 @@ require ( github.com/yuin/goldmark v1.1.28 // indirect github.com/zalando/go-keyring v0.0.0-20200121091418-667557018717 go.etcd.io/bbolt v1.3.4 + go.uber.org/multierr v1.5.0 golang.org/x/crypto v0.0.0-20200406173513-056763e48d71 golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e // indirect golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d diff --git a/go.sum b/go.sum index a5113e83c028..7296f6c650a0 100644 --- a/go.sum +++ b/go.sum @@ -64,7 +64,6 @@ github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= github.com/dlclark/regexp2 v1.2.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/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= @@ -73,9 +72,7 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= -github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= -github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM= github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= github.com/go-git/go-git/v5 v5.0.1-0.20200501143051-8543c83ab70a h1:A/zz5cIuYTstFq0JgoOS/Aj4x4+r0XulbQR/STk6RqY= @@ -137,7 +134,6 @@ github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg= github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -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/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= @@ -145,7 +141,6 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u 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/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -181,7 +176,6 @@ github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+i github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= -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/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -194,6 +188,8 @@ github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx 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/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +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.0.0-20191216070243-e5efeac4e302 h1:jOh3Kh03uOFkRPV3PI4Am5tqACv2aELgbPgr7YgNX00= github.com/muesli/reflow v0.0.0-20191216070243-e5efeac4e302/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -223,6 +219,7 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.0 h1:IZRgg4sfrDH7nsAD1Y/Nwj+GzIfEwpJSLjCaNC3SbsI= github.com/rogpeppe/go-internal v1.6.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -286,7 +283,6 @@ github.com/twpayne/go-xdg/v3 v3.1.0/go.mod h1:z6/LkoG2gtuzrsxEqPRoEjccS5Q35GK+lg github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= @@ -300,17 +296,27 @@ go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg= go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= 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-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200406173513-056763e48d71 h1:DOmugCavvUtnUD114C1Wh+UgTgQZ4pMLzXxi1pSt+/Y= golang.org/x/crypto v0.0.0-20200406173513-056763e48d71/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/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-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 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-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -322,6 +328,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2eP golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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 h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -334,6 +341,7 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -362,6 +370,11 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5 h1:hKsoRgsbwY1NafxrwTs+k64bikrLBkAgPir1TNCj3Zs= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs= @@ -393,7 +406,6 @@ gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ= gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -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.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -404,3 +416,5 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/v2/internal/chezmoi/attributes.go b/v2/internal/chezmoi/attributes.go new file mode 100644 index 000000000000..85ef9ddbaa55 --- /dev/null +++ b/v2/internal/chezmoi/attributes.go @@ -0,0 +1,177 @@ +package chezmoi + +import ( + "strings" +) + +// A SourceFileTargetType is a the type of a target represented by a file in the +// source state. A file in the source state can represent a file, script, or +// symlink in the target state. +type SourceFileTargetType int + +// Source file types. +const ( + SourceFileTypeFile SourceFileTargetType = iota + SourceFileTypeScript + SourceFileTypeSymlink +) + +// DirAttributes holds attributes parsed from a source directory name. +type DirAttributes struct { + Name string + Exact bool + Private bool +} + +// A FileAttributes holds attributes parsed from a source file name. +type FileAttributes struct { + Name string + Type SourceFileTargetType + Empty bool + Encrypted bool + Executable bool + Once bool + Private bool + Template bool +} + +// ParseDirAttributes parses a single directory name in the source state. +func ParseDirAttributes(sourceName string) DirAttributes { + var ( + name = sourceName + exact = false + private = false + ) + if strings.HasPrefix(name, exactPrefix) { + name = strings.TrimPrefix(name, exactPrefix) + exact = true + } + if strings.HasPrefix(name, privatePrefix) { + name = strings.TrimPrefix(name, privatePrefix) + private = true + } + if strings.HasPrefix(name, dotPrefix) { + name = "." + strings.TrimPrefix(name, dotPrefix) + } + return DirAttributes{ + Name: name, + Exact: exact, + Private: private, + } +} + +// SourceName returns da's source name. +func (da DirAttributes) SourceName() string { + sourceName := "" + if da.Exact { + sourceName += exactPrefix + } + if da.Private { + sourceName += privatePrefix + } + if strings.HasPrefix(da.Name, ".") { + sourceName += dotPrefix + strings.TrimPrefix(da.Name, ".") + } else { + sourceName += da.Name + } + return sourceName +} + +// ParseFileAttributes parses a source file name in the source state. +func ParseFileAttributes(sourceName string) FileAttributes { + var ( + name = sourceName + typ = SourceFileTypeFile + empty = false + encrypted = false + executable = false + once = false + private = false + template = false + ) + switch { + case strings.HasPrefix(name, runPrefix): + name = strings.TrimPrefix(name, runPrefix) + typ = SourceFileTypeScript + if strings.HasPrefix(name, oncePrefix) { + name = strings.TrimPrefix(name, oncePrefix) + once = true + } + case strings.HasPrefix(name, symlinkPrefix): + name = strings.TrimPrefix(name, symlinkPrefix) + typ = SourceFileTypeSymlink + if strings.HasPrefix(name, dotPrefix) { + name = "." + strings.TrimPrefix(name, dotPrefix) + } + default: + if strings.HasPrefix(name, encryptedPrefix) { + name = strings.TrimPrefix(name, encryptedPrefix) + encrypted = true + } + if strings.HasPrefix(name, privatePrefix) { + name = strings.TrimPrefix(name, privatePrefix) + private = true + } + if strings.HasPrefix(name, emptyPrefix) { + name = strings.TrimPrefix(name, emptyPrefix) + empty = true + } + if strings.HasPrefix(name, executablePrefix) { + name = strings.TrimPrefix(name, executablePrefix) + executable = true + } + if strings.HasPrefix(name, dotPrefix) { + name = "." + strings.TrimPrefix(name, dotPrefix) + } + } + if strings.HasSuffix(name, templateSuffix) { + name = strings.TrimSuffix(name, templateSuffix) + template = true + } + return FileAttributes{ + Name: name, + Type: typ, + Empty: empty, + Encrypted: encrypted, + Executable: executable, + Once: once, + Private: private, + Template: template, + } +} + +// SourceName returns fa's source name. +func (fa FileAttributes) SourceName() string { + sourceName := "" + switch fa.Type { + case SourceFileTypeFile: + if fa.Encrypted { + sourceName += encryptedPrefix + } + if fa.Private { + sourceName += privatePrefix + } + if fa.Empty { + sourceName += emptyPrefix + } + if fa.Executable { + sourceName += executablePrefix + } + case SourceFileTypeScript: + sourceName = runPrefix + if fa.Once { + sourceName += oncePrefix + } + case SourceFileTypeSymlink: + sourceName = symlinkPrefix + } + if strings.HasPrefix(fa.Name, ".") { + sourceName += dotPrefix + strings.TrimPrefix(fa.Name, ".") + } else { + sourceName += fa.Name + } + if fa.Template { + sourceName += templateSuffix + } + return sourceName +} diff --git a/v2/internal/chezmoi/attributes_test.go b/v2/internal/chezmoi/attributes_test.go new file mode 100644 index 000000000000..a9d8d5c7c471 --- /dev/null +++ b/v2/internal/chezmoi/attributes_test.go @@ -0,0 +1,98 @@ +package chezmoi + +import ( + "testing" + + "github.com/muesli/combinator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDirAttributes tests DirAttributes by round-tripping between directory +// names and DirAttributes. +func TestDirAttributes(t *testing.T) { + testData := struct { + Name []string + Exact []bool + Private []bool + }{ + Name: []string{ + ".dir", + "dir.tmpl", + "dir", + "empty_dir", + "encrypted_dir", + "executable_dir", + "once_dir", + "run_dir", + "run_once_dir", + "symlink_dir", + }, + Exact: []bool{false, true}, + Private: []bool{false, true}, + } + var dirAttributes []DirAttributes + require.NoError(t, combinator.Generate(&dirAttributes, testData)) + for _, da := range dirAttributes { + actualSourceName := da.SourceName() + actualDA := ParseDirAttributes(actualSourceName) + assert.Equal(t, da, actualDA) + assert.Equal(t, actualSourceName, actualDA.SourceName()) + } +} + +// TestFileAttributes tests FileAttributes by round-tripping between file names +// and FileAttributes. +func TestFileAttributes(t *testing.T) { + var fileAttributes []FileAttributes + require.NoError(t, combinator.Generate(&fileAttributes, struct { + Type SourceFileTargetType + Name []string + Empty []bool + Encrypted []bool + Executable []bool + Private []bool + Template []bool + }{ + Type: SourceFileTypeFile, + Name: []string{ + ".name", + "exact_name", + "name", + }, + Empty: []bool{false, true}, + Encrypted: []bool{false, true}, + Executable: []bool{false, true}, + Private: []bool{false, true}, + Template: []bool{false, true}, + })) + require.NoError(t, combinator.Generate(&fileAttributes, struct { + Type SourceFileTargetType + Name []string + Once []bool + }{ + Type: SourceFileTypeScript, + Name: []string{ + "exact_name", + "name", + }, + Once: []bool{false, true}, + })) + require.NoError(t, combinator.Generate(&fileAttributes, struct { + Type SourceFileTargetType + Name []string + Once []bool + }{ + Type: SourceFileTypeSymlink, + Name: []string{ + "exact_name", + "name", + }, + })) + for _, fa := range fileAttributes { + actualSourceName := fa.SourceName() + actualFA := ParseFileAttributes(actualSourceName) + assert.Equal(t, fa, actualFA) + assert.Equal(t, actualSourceName, actualFA.SourceName()) + } +} diff --git a/v2/internal/chezmoi/autotemplate.go b/v2/internal/chezmoi/autotemplate.go new file mode 100644 index 000000000000..3f370a8ff863 --- /dev/null +++ b/v2/internal/chezmoi/autotemplate.go @@ -0,0 +1,90 @@ +package chezmoi + +import ( + "sort" + "strings" +) + +type templateVariable struct { + name string + value string +} + +type byValueLength []templateVariable + +func (b byValueLength) Len() int { return len(b) } +func (b byValueLength) Less(i, j int) bool { + switch { + case len(b[i].value) < len(b[j].value): + return true + case len(b[i].value) == len(b[j].value): + // Fallback to name + return b[i].name > b[j].name + default: + return false + } +} +func (b byValueLength) Swap(i, j int) { b[i], b[j] = b[j], b[i] } + +func autoTemplate(contents []byte, data map[string]interface{}) []byte { + // FIXME this naive approach will generate incorrect templates if the + // variable names match variable values + // FIXME the algorithm here is probably O(N^2), we can do better + variables := extractVariables(nil, nil, data) + sort.Sort(sort.Reverse(byValueLength(variables))) + contentsStr := string(contents) + for _, variable := range variables { + if variable.value == "" { + continue + } + index := strings.Index(contentsStr, variable.value) + for index != -1 && index != len(contentsStr) { + if !inWord(contentsStr, index) && !inWord(contentsStr, index+len(variable.value)) { + // Replace variable.value which is on word boundaries at both + // ends. + replacement := "{{ ." + variable.name + " }}" + contentsStr = contentsStr[:index] + replacement + contentsStr[index+len(variable.value):] + index += len(replacement) + } else { + // Otherwise, keep looking. Consume at least one byte so we + // make progress. + index++ + } + // Look for the next occurrence of variable.value. + j := strings.Index(contentsStr[index:], variable.value) + if j == -1 { + // No more occurrences found, so terminate the loop. + break + } else { + // Advance to the next occurrence. + index += j + } + } + } + return []byte(contentsStr) +} + +func extractVariables(variables []templateVariable, parent []string, data map[string]interface{}) []templateVariable { + for name, value := range data { + switch value := value.(type) { + case string: + variables = append(variables, templateVariable{ + name: strings.Join(append(parent, name), "."), + value: value, + }) + case map[string]interface{}: + variables = extractVariables(variables, append(parent, name), value) + } + } + return variables +} + +// inWord returns true if splitting s at position i would split a word. +func inWord(s string, i int) bool { + return i > 0 && i < len(s) && isWord(s[i-1]) && isWord(s[i]) +} + +// isWord returns true if b is a word byte. +func isWord(b byte) bool { + return '0' <= b && b <= '9' || 'A' <= b && b <= 'Z' || 'a' <= b && b <= 'z' +} diff --git a/v2/internal/chezmoi/autotemplate_test.go b/v2/internal/chezmoi/autotemplate_test.go new file mode 100644 index 000000000000..ea264165bf6b --- /dev/null +++ b/v2/internal/chezmoi/autotemplate_test.go @@ -0,0 +1,168 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAutoTemplate(t *testing.T) { + for _, tc := range []struct { + name string + contentsStr string + data map[string]interface{} + wantStr string + }{ + { + name: "simple", + contentsStr: "email = john.smith@company.com\n", + data: map[string]interface{}{ + "email": "john.smith@company.com", + }, + wantStr: "email = {{ .email }}\n", + }, + { + name: "longest_first", + contentsStr: "name = John Smith\nfirstName = John\n", + data: map[string]interface{}{ + "name": "John Smith", + "firstName": "John", + }, + wantStr: "name = {{ .name }}\nfirstName = {{ .firstName }}\n", + }, + { + name: "alphabetical_first", + contentsStr: "name = John Smith\n", + data: map[string]interface{}{ + "alpha": "John Smith", + "beta": "John Smith", + "gamma": "John Smith", + }, + wantStr: "name = {{ .alpha }}\n", + }, + { + name: "nested_values", + contentsStr: "email = john.smith@company.com\n", + data: map[string]interface{}{ + "personal": map[string]interface{}{ + "email": "john.smith@company.com", + }, + }, + wantStr: "email = {{ .personal.email }}\n", + }, + { + name: "only_replace_words", + contentsStr: "darwinian evolution", + data: map[string]interface{}{ + "os": "darwin", + }, + wantStr: "darwinian evolution", // not "{{ .os }}ian evolution" + }, + { + name: "longest_match_first", + contentsStr: "/home/user", + data: map[string]interface{}{ + "homedir": "/home/user", + }, + wantStr: "{{ .homedir }}", + }, + { + name: "longest_match_first_prefix", + contentsStr: "HOME=/home/user", + data: map[string]interface{}{ + "homedir": "/home/user", + }, + wantStr: "HOME={{ .homedir }}", + }, + { + name: "longest_match_first_suffix", + contentsStr: "/home/user/something", + data: map[string]interface{}{ + "homedir": "/home/user", + }, + wantStr: "{{ .homedir }}/something", + }, + { + name: "longest_match_first_prefix_and_suffix", + contentsStr: "HOME=/home/user/something", + data: map[string]interface{}{ + "homedir": "/home/user", + }, + wantStr: "HOME={{ .homedir }}/something", + }, + { + name: "words_only", + contentsStr: "aaa aa a aa aaa aa a aa aaa", + data: map[string]interface{}{ + "alpha": "a", + }, + wantStr: "aaa aa {{ .alpha }} aa aaa aa {{ .alpha }} aa aaa", + }, + { + name: "words_only_2", + contentsStr: "aaa aa a aa aaa aa a aa aaa", + data: map[string]interface{}{ + "alpha": "aa", + }, + wantStr: "aaa {{ .alpha }} a {{ .alpha }} aaa {{ .alpha }} a {{ .alpha }} aaa", + }, + { + name: "words_only_3", + contentsStr: "aaa aa a aa aaa aa a aa aaa", + data: map[string]interface{}{ + "alpha": "aaa", + }, + wantStr: "{{ .alpha }} aa a aa {{ .alpha }} aa a aa {{ .alpha }}", + }, + { + name: "skip_empty", + contentsStr: "a", + data: map[string]interface{}{ + "empty": "", + }, + wantStr: "a", + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.wantStr, string(autoTemplate([]byte(tc.contentsStr), tc.data))) + }) + } +} + +func TestInWord(t *testing.T) { + for _, tc := range []struct { + s string + i int + want bool + }{ + {s: "", i: 0, want: false}, + {s: "a", i: 0, want: false}, + {s: "a", i: 1, want: false}, + {s: "ab", i: 0, want: false}, + {s: "ab", i: 1, want: true}, + {s: "ab", i: 2, want: false}, + {s: "abc", i: 0, want: false}, + {s: "abc", i: 1, want: true}, + {s: "abc", i: 2, want: true}, + {s: "abc", i: 3, want: false}, + {s: " abc ", i: 0, want: false}, + {s: " abc ", i: 1, want: false}, + {s: " abc ", i: 2, want: true}, + {s: " abc ", i: 3, want: true}, + {s: " abc ", i: 4, want: false}, + {s: " abc ", i: 5, want: false}, + {s: "/home/user", i: 0, want: false}, + {s: "/home/user", i: 1, want: false}, + {s: "/home/user", i: 2, want: true}, + {s: "/home/user", i: 3, want: true}, + {s: "/home/user", i: 4, want: true}, + {s: "/home/user", i: 5, want: false}, + {s: "/home/user", i: 6, want: false}, + {s: "/home/user", i: 7, want: true}, + {s: "/home/user", i: 8, want: true}, + {s: "/home/user", i: 9, want: true}, + {s: "/home/user", i: 10, want: false}, + } { + assert.Equal(t, tc.want, inWord(tc.s, tc.i)) + } +} diff --git a/v2/internal/chezmoi/boltpersistentstate.go b/v2/internal/chezmoi/boltpersistentstate.go new file mode 100644 index 000000000000..771dc9ff23c9 --- /dev/null +++ b/v2/internal/chezmoi/boltpersistentstate.go @@ -0,0 +1,121 @@ +package chezmoi + +import ( + "os" + "path" + + vfs "github.com/twpayne/go-vfs" + bolt "go.etcd.io/bbolt" +) + +// A BoltPersistentState is a state persisted with bolt. +type BoltPersistentState struct { + fs vfs.FS + path string + options *bolt.Options + db *bolt.DB +} + +// NewBoltPersistentState returns a new BoltPersistentState. +func NewBoltPersistentState(fs vfs.FS, path string, options *bolt.Options) (*BoltPersistentState, error) { + b := &BoltPersistentState{ + fs: fs, + path: path, + options: options, + } + _, err := fs.Stat(b.path) + switch { + case err == nil: + if err := b.openDB(); err != nil { + return nil, err + } + case os.IsNotExist(err): + default: + return nil, err + } + return b, nil +} + +// Close closes b. +func (b *BoltPersistentState) Close() error { + if b.db == nil { + return nil + } + if err := b.db.Close(); err != nil { + return err + } + b.db = nil + return nil +} + +// Delete deletes the value associate with key in bucket. If bucket or key does +// not exist then Delete does nothing. +func (b *BoltPersistentState) Delete(bucket, key []byte) error { + if b.db == nil { + return nil + } + return b.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucket) + if b == nil { + return nil + } + return b.Delete(key) + }) +} + +// Get returns the value associated with key in bucket. +func (b *BoltPersistentState) Get(bucket, key []byte) ([]byte, error) { + if b.db == nil { + return nil, nil + } + var value []byte + if err := b.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(bucket) + if b == nil { + return nil + } + v := b.Get(key) + if v != nil { + value = make([]byte, len(v)) + copy(value, v) + } + return nil + }); err != nil { + return nil, err + } + return value, nil +} + +// Set sets the value associated with key in bucket. bucket will be created if +// it does not already exist. +func (b *BoltPersistentState) Set(bucket, key, value []byte) error { + if b.db == nil { + if err := b.openDB(); err != nil { + return err + } + } + return b.db.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists(bucket) + if err != nil { + return err + } + return b.Put(key, value) + }) +} + +func (b *BoltPersistentState) openDB() error { + if err := vfs.MkdirAll(b.fs, path.Dir(b.path), 0o777); err != nil { + return err + } + var options bolt.Options + if b.options != nil { + options = *b.options + } + options.OpenFile = b.fs.OpenFile + db, err := bolt.Open(b.path, 0o600, &options) + if err != nil { + return err + } + b.db = db + return err +} diff --git a/v2/internal/chezmoi/boltpersistentstate_test.go b/v2/internal/chezmoi/boltpersistentstate_test.go new file mode 100644 index 000000000000..7d8d83cf6dc7 --- /dev/null +++ b/v2/internal/chezmoi/boltpersistentstate_test.go @@ -0,0 +1,120 @@ +package chezmoi + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs/vfst" + bolt "go.etcd.io/bbolt" +) + +var _ PersistentState = &BoltPersistentState{} + +func TestBoltPersistentState(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{ + "/home/user/.config/chezmoi": &vfst.Dir{Perm: 0o755}, + }) + require.NoError(t, err) + defer cleanup() + + path := "/home/user/.config/chezmoi/chezmoistate.boltdb" + b, err := NewBoltPersistentState(fs, path, nil) + require.NoError(t, err) + vfst.RunTests(t, fs, "", + vfst.TestPath(path, + vfst.TestDoesNotExist, + ), + ) + + var ( + bucket = []byte("bucket") + key = []byte("key") + value = []byte("value") + ) + + require.NoError(t, b.Delete(bucket, key)) + vfst.RunTests(t, fs, "", + vfst.TestPath(path, + vfst.TestDoesNotExist, + ), + ) + + actualValue, err := b.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, []byte(nil), actualValue) + vfst.RunTests(t, fs, "", + vfst.TestPath(path, + vfst.TestDoesNotExist, + ), + ) + + assert.NoError(t, b.Set(bucket, key, value)) + vfst.RunTests(t, fs, "", + vfst.TestPath(path, + vfst.TestModeIsRegular, + ), + ) + + actualValue, err = b.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value, actualValue) + + require.NoError(t, b.Close()) + + b, err = NewBoltPersistentState(fs, path, nil) + require.NoError(t, err) + + require.NoError(t, b.Delete(bucket, key)) + + actualValue, err = b.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, []byte(nil), actualValue) +} + +func TestBoltPersistentStateReadOnly(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{ + "/home/user/.config/chezmoi": &vfst.Dir{Perm: 0o755}, + }) + require.NoError(t, err) + defer cleanup() + + path := "/home/user/.config/chezmoi/chezmoistate.boltdb" + bucket := []byte("bucket") + key := []byte("key") + value := []byte("value") + + a, err := NewBoltPersistentState(fs, path, nil) + require.NoError(t, err) + require.NoError(t, a.Set(bucket, key, value)) + require.NoError(t, a.Close()) + + b, err := NewBoltPersistentState(fs, path, &bolt.Options{ + ReadOnly: true, + Timeout: 1 * time.Second, + }) + require.NoError(t, err) + defer b.Close() + + c, err := NewBoltPersistentState(fs, path, &bolt.Options{ + ReadOnly: true, + Timeout: 1 * time.Second, + }) + require.NoError(t, err) + defer c.Close() + + actualValueB, err := b.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value, actualValueB) + + actualValueC, err := c.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value, actualValueC) + + assert.Error(t, b.Set(bucket, key, value)) + assert.Error(t, c.Set(bucket, key, value)) + + require.NoError(t, b.Close()) + require.NoError(t, c.Close()) +} diff --git a/v2/internal/chezmoi/canarysystem.go b/v2/internal/chezmoi/canarysystem.go new file mode 100644 index 000000000000..af0d447baf84 --- /dev/null +++ b/v2/internal/chezmoi/canarysystem.go @@ -0,0 +1,120 @@ +package chezmoi + +import ( + "os" + "os/exec" +) + +// An CanarySystem wraps a System and records if any of its mutating +// methods are called. +type CanarySystem struct { + s System + mutated bool +} + +// NewCanarySystem returns a new CanarySystem. +func NewCanarySystem(s System) *CanarySystem { + return &CanarySystem{ + s: s, + mutated: false, + } +} + +// Chmod implements System.Chmod. +func (s *CanarySystem) Chmod(name string, mode os.FileMode) error { + s.mutated = true + return s.s.Chmod(name, mode) +} + +// Delete implements System.Delete. +func (s *CanarySystem) Delete(bucket, key []byte) error { + s.mutated = true + return s.s.Delete(bucket, key) +} + +// Get implements System.Get. +func (s *CanarySystem) Get(bucket, key []byte) ([]byte, error) { + return s.s.Get(bucket, key) +} + +// Glob implements System.Glob. +func (s *CanarySystem) Glob(pattern string) ([]string, error) { + return s.s.Glob(pattern) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *CanarySystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return s.s.IdempotentCmdOutput(cmd) +} + +// Mkdir implements System.Mkdir. +func (s *CanarySystem) Mkdir(name string, perm os.FileMode) error { + s.mutated = true + return s.s.Mkdir(name, perm) +} + +// Lstat implements System.Lstat. +func (s *CanarySystem) Lstat(path string) (os.FileInfo, error) { + return s.s.Lstat(path) +} + +// Mutated returns true if any of its mutating methods have been called. +func (s *CanarySystem) Mutated() bool { + return s.mutated +} + +// ReadDir implements System.ReadDir. +func (s *CanarySystem) ReadDir(dirname string) ([]os.FileInfo, error) { + return s.s.ReadDir(dirname) +} + +// ReadFile implements System.ReadFile. +func (s *CanarySystem) ReadFile(filename string) ([]byte, error) { + return s.s.ReadFile(filename) +} + +// Readlink implements System.Readlink. +func (s *CanarySystem) Readlink(name string) (string, error) { + return s.s.Readlink(name) +} + +// RemoveAll implements System.RemoveAll. +func (s *CanarySystem) RemoveAll(name string) error { + s.mutated = true + return s.s.RemoveAll(name) +} + +// Rename implements System.Rename. +func (s *CanarySystem) Rename(oldpath, newpath string) error { + s.mutated = true + return s.s.Rename(oldpath, newpath) +} + +// RunScript implements System.RunScript. +func (s *CanarySystem) RunScript(scriptname string, data []byte) error { + s.mutated = true + return s.s.RunScript(scriptname, data) +} + +// Set implements System.Set. +func (s *CanarySystem) Set(bucket, key, value []byte) error { + s.mutated = true + return s.s.Set(bucket, key, value) +} + +// Stat implements System.Stat. +func (s *CanarySystem) Stat(path string) (os.FileInfo, error) { + return s.s.Stat(path) +} + +// WriteFile implements System.WriteFile. +func (s *CanarySystem) WriteFile(name string, data []byte, perm os.FileMode) error { + s.mutated = true + return s.s.WriteFile(name, data, perm) +} + +// WriteSymlink implements System.WriteSymlink. +func (s *CanarySystem) WriteSymlink(oldname, newname string) error { + s.mutated = true + return s.s.WriteSymlink(oldname, newname) +} diff --git a/v2/internal/chezmoi/canarysystem_test.go b/v2/internal/chezmoi/canarysystem_test.go new file mode 100644 index 000000000000..1f7e6932782a --- /dev/null +++ b/v2/internal/chezmoi/canarysystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &CanarySystem{} diff --git a/v2/internal/chezmoi/chezmoi.go b/v2/internal/chezmoi/chezmoi.go new file mode 100644 index 000000000000..9996a5a74928 --- /dev/null +++ b/v2/internal/chezmoi/chezmoi.go @@ -0,0 +1,67 @@ +package chezmoi + +import ( + "fmt" + "os" + "runtime" +) + +// Configuration constants. +const ( + posixFileModes = runtime.GOOS != "windows" + pathSeparator = '/' + pathSeparatorStr = string(pathSeparator) + ignorePrefix = "." +) + +// Configuration variables. +var ( + // DefaultTemplateOptions are the default template options. + DefaultTemplateOptions = []string{"missingkey=error"} + + // DefaultUmask is the default umask. + DefaultUmask = os.FileMode(0o22) + + scriptOnceStateBucket = []byte("script") +) + +// Suffixes and prefixes. +const ( + dotPrefix = "dot_" + emptyPrefix = "empty_" + encryptedPrefix = "encrypted_" + exactPrefix = "exact_" + executablePrefix = "executable_" + oncePrefix = "once_" + privatePrefix = "private_" + runPrefix = "run_" + symlinkPrefix = "symlink_" + templateSuffix = ".tmpl" +) + +// Special file names. +const ( + chezmoiPrefix = ".chezmoi" + + ignoreName = chezmoiPrefix + "ignore" + removeName = chezmoiPrefix + "remove" + templatesDirName = chezmoiPrefix + "templates" + versionName = chezmoiPrefix + "version" +) + +var modeTypeNames = map[os.FileMode]string{ + 0: "file", + os.ModeDir: "dir", + os.ModeSymlink: "symlink", + os.ModeNamedPipe: "named pipe", + os.ModeSocket: "socket", + os.ModeDevice: "device", + os.ModeCharDevice: "char device", +} + +func modeTypeName(mode os.FileMode) string { + if name, ok := modeTypeNames[mode&os.ModeType]; ok { + return name + } + return fmt.Sprintf("unknown (%d)", mode&os.ModeType) +} diff --git a/v2/internal/chezmoi/datasystem.go b/v2/internal/chezmoi/datasystem.go new file mode 100644 index 000000000000..e349b7904020 --- /dev/null +++ b/v2/internal/chezmoi/datasystem.go @@ -0,0 +1,118 @@ +package chezmoi + +import "os" + +type dataType string + +const ( + dataTypeDir dataType = "dir" + dataTypeFile dataType = "file" + dataTypeScript dataType = "script" + dataTypeSymlink dataType = "symlink" +) + +// A DataSystem is a System that writes to a data file. +type DataSystem struct { + nullSystem + data map[string]interface{} +} + +type dirData struct { + Type dataType `json:"type" yaml:"type"` + Name string `json:"name" yaml:"name"` + Perm os.FileMode `json:"perm" yaml:"perm"` +} + +type fileData struct { + Type dataType `json:"type" yaml:"type"` + Name string `json:"name" yaml:"name"` + Contents string `json:"contents" yaml:"contents"` + Perm os.FileMode `json:"perm" yaml:"perm"` +} + +type scriptData struct { + Type dataType `json:"type" yaml:"type"` + Name string `json:"name" yaml:"name"` + Contents string `json:"contents" yaml:"contents"` +} + +type symlinkData struct { + Type dataType `json:"type" yaml:"type"` + Name string `json:"name" yaml:"name"` + Linkname string `json:"linkname" yaml:"linkname"` +} + +// NewDataSystem returns a new DataSystem that accumulates data. +func NewDataSystem() *DataSystem { + return &DataSystem{ + data: make(map[string]interface{}), + } +} + +// Chmod implements System.Chmod. +func (s *DataSystem) Chmod(name string, mode os.FileMode) error { + return os.ErrPermission +} + +// Data returns s's data. +func (s *DataSystem) Data() interface{} { + return s.data +} + +// Mkdir implements System.Mkdir. +func (s *DataSystem) Mkdir(dirname string, perm os.FileMode) error { + if _, exists := s.data[dirname]; exists { + return os.ErrExist + } + s.data[dirname] = &dirData{ + Type: dataTypeDir, + Name: dirname, + Perm: perm, + } + return nil +} + +// RemoveAll implements System.RemoveAll. +func (s *DataSystem) RemoveAll(name string) error { + return os.ErrPermission +} + +// RunScript implements System.RunScript. +func (s *DataSystem) RunScript(scriptname string, data []byte) error { + if _, exists := s.data[scriptname]; exists { + return os.ErrExist + } + s.data[scriptname] = &scriptData{ + Type: dataTypeScript, + Name: scriptname, + Contents: string(data), + } + return nil +} + +// WriteFile implements System.WriteFile. +func (s *DataSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { + if _, exists := s.data[filename]; exists { + return os.ErrExist + } + s.data[filename] = &fileData{ + Type: dataTypeFile, + Name: filename, + Contents: string(data), + Perm: perm, + } + return nil +} + +// WriteSymlink implements System.WriteSymlink. +func (s *DataSystem) WriteSymlink(oldname, newname string) error { + if _, exists := s.data[newname]; exists { + return os.ErrExist + } + s.data[newname] = &symlinkData{ + Type: dataTypeSymlink, + Name: newname, + Linkname: oldname, + } + return nil +} diff --git a/v2/internal/chezmoi/datasystem_test.go b/v2/internal/chezmoi/datasystem_test.go new file mode 100644 index 000000000000..6642a8aa51ca --- /dev/null +++ b/v2/internal/chezmoi/datasystem_test.go @@ -0,0 +1,83 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs/vfst" +) + +var _ System = &DataSystem{} + +func TestDataSystem(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + ".chezmoiremove": "*.txt\n", + ".chezmoiversion": "1.2.3\n", + ".chezmoitemplates": map[string]interface{}{ + "foo": "bar", + }, + "README.md": "", + "dir": map[string]interface{}{ + "foo": "bar", + }, + "run_script": "#!/bin/sh\n", + "symlink_symlink": "bar", + }, + }) + require.NoError(t, err) + defer cleanup() + + s := NewSourceState( + WithSystem(NewRealSystem(fs, newTestPersistentState())), + WithSourcePath("/home/user/.local/share/chezmoi"), + ) + require.NoError(t, s.Read()) + require.NoError(t, s.Evaluate()) + + dataSystem := NewDataSystem() + require.NoError(t, s.ApplyAll(dataSystem, vfst.DefaultUmask, "")) + expectedData := map[string]interface{}{ + "dir": &dirData{ + Type: dataTypeDir, + Name: "dir", + Perm: 0o755, + }, + "dir/foo": &fileData{ + Type: dataTypeFile, + Name: "dir/foo", + Contents: "bar", + Perm: 0o644, + }, + "script": &scriptData{ + Type: dataTypeScript, + Name: "script", + Contents: "#!/bin/sh\n", + }, + "symlink": &symlinkData{ + Type: dataTypeSymlink, + Name: "symlink", + Linkname: "bar", + }, + } + actualData := dataSystem.Data() + assert.Equal(t, expectedData, actualData) + + for _, serializationFormat := range []SerializationFormat{ + JSONSerializationFormat, + TOMLSerializationFormat, + YAMLSerializationFormat, + } { + t.Run(serializationFormat.Name(), func(t *testing.T) { + expectedSerializedData, err := serializationFormat.Serialize(expectedData) + require.NoError(t, err) + actualSerializedData, err := serializationFormat.Serialize(actualData) + require.NoError(t, err) + // Compare strings, rather than []bytes, to make any diff easier to + // interpret. + assert.Equal(t, string(expectedSerializedData), string(actualSerializedData)) + }) + } +} diff --git a/v2/internal/chezmoi/debugsystem.go b/v2/internal/chezmoi/debugsystem.go new file mode 100644 index 000000000000..81cc12bc2098 --- /dev/null +++ b/v2/internal/chezmoi/debugsystem.go @@ -0,0 +1,199 @@ +package chezmoi + +import ( + "log" + "os" + "os/exec" + "time" +) + +// A DebugSystem wraps a System and logs all of the actions it executes. +type DebugSystem struct { + s System +} + +// NewDebugSystem returns a new DebugSystem. +func NewDebugSystem(s System) *DebugSystem { + return &DebugSystem{ + s: s, + } +} + +// Chmod implements System.Chmod. +func (s *DebugSystem) Chmod(name string, mode os.FileMode) error { + return Debugf("Chmod(%q, 0o%o)", []interface{}{name, mode}, func() error { + return s.s.Chmod(name, mode) + }) +} + +// Delete implements System.Delete. +func (s *DebugSystem) Delete(bucket, key []byte) error { + return Debugf("Delete(%q, %q)", []interface{}{string(bucket), string(key)}, func() error { + return s.s.Delete(bucket, key) + }) +} + +// Get implements System.Get. +func (s *DebugSystem) Get(bucket, key []byte) ([]byte, error) { + var value []byte + err := Debugf("Get(%q, %q)", []interface{}{string(bucket), string(key)}, func() error { + var err error + value, err = s.s.Get(bucket, key) + return err + }) + return value, err +} + +// Glob implements System.Glob. +func (s *DebugSystem) Glob(name string) ([]string, error) { + var matches []string + err := Debugf("Glob(%q)", []interface{}{name}, func() error { + var err error + matches, err = s.s.Glob(name) + return err + }) + return matches, err +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *DebugSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + var output []byte + cmdStr := ShellQuoteArgs(append([]string{cmd.Path}, cmd.Args[1:]...)) + err := Debugf("IdempotentCmdOutput(%q)", []interface{}{cmdStr}, func() error { + var err error + output, err = s.s.IdempotentCmdOutput(cmd) + return err + }) + return output, err +} + +// Lstat implements System.Lstat. +func (s *DebugSystem) Lstat(name string) (os.FileInfo, error) { + var info os.FileInfo + err := Debugf("Lstat(%q)", []interface{}{name}, func() error { + var err error + info, err = s.s.Lstat(name) + return err + }) + return info, err +} + +// Mkdir implements System.Mkdir. +func (s *DebugSystem) Mkdir(name string, perm os.FileMode) error { + return Debugf("Mkdir(%q, 0o%o)", []interface{}{name, perm}, func() error { + return s.s.Mkdir(name, perm) + }) +} + +// ReadDir implements System.ReadDir. +func (s *DebugSystem) ReadDir(name string) ([]os.FileInfo, error) { + var infos []os.FileInfo + err := Debugf("ReadDir(%q)", []interface{}{name}, func() error { + var err error + infos, err = s.s.ReadDir(name) + return err + }) + return infos, err +} + +// ReadFile implements System.ReadFile. +func (s *DebugSystem) ReadFile(filename string) ([]byte, error) { + var data []byte + err := Debugf("ReadFile(%q)", []interface{}{filename}, func() error { + var err error + data, err = s.s.ReadFile(filename) + return err + }) + return data, err +} + +// Readlink implements System.Readlink. +func (s *DebugSystem) Readlink(name string) (string, error) { + var linkname string + err := Debugf("Readlink(%q)", []interface{}{name}, func() error { + var err error + linkname, err = s.s.Readlink(name) + return err + }) + return linkname, err +} + +// RemoveAll implements System.RemoveAll. +func (s *DebugSystem) RemoveAll(name string) error { + return Debugf("RemoveAll(%q)", []interface{}{name}, func() error { + return s.s.RemoveAll(name) + }) +} + +// Rename implements System.Rename. +func (s *DebugSystem) Rename(oldpath, newpath string) error { + return Debugf("Rename(%q, %q)", []interface{}{oldpath, newpath}, func() error { + return s.Rename(oldpath, newpath) + }) +} + +// RunScript implements System.RunScript. +func (s *DebugSystem) RunScript(scriptname string, data []byte) error { + return Debugf("Run(%q, _)", []interface{}{scriptname}, func() error { + return s.s.RunScript(scriptname, data) + }) +} + +// Set implements System.Set. +func (s *DebugSystem) Set(bucket, key, value []byte) error { + return Debugf("Set(%q, %q, %q)", []interface{}{string(bucket), string(key), string(value)}, func() error { + return s.s.Set(bucket, key, value) + }) +} + +// Stat implements System.Stat. +func (s *DebugSystem) Stat(name string) (os.FileInfo, error) { + var info os.FileInfo + err := Debugf("Stat(%q)", []interface{}{name}, func() error { + var err error + info, err = s.s.Stat(name) + return err + }) + return info, err +} + +// WriteFile implements System.WriteFile. +func (s *DebugSystem) WriteFile(name string, data []byte, perm os.FileMode) error { + return Debugf("WriteFile(%q, _, 0%o, _)", []interface{}{name, perm}, func() error { + return s.s.WriteFile(name, data, perm) + }) +} + +// WriteSymlink implements System.WriteSymlink. +func (s *DebugSystem) WriteSymlink(oldname, newname string) error { + return Debugf("WriteSymlink(%q, %q)", []interface{}{oldname, newname}, func() error { + return s.s.WriteSymlink(oldname, newname) + }) +} + +// Debugf logs debugging information about calling f. +func Debugf(format string, args []interface{}, f func() error) error { + errChan := make(chan error) + start := time.Now() + go func(errChan chan<- error) { + errChan <- f() + }(errChan) + select { + case err := <-errChan: + if err == nil { + log.Printf(format+" (%s)", append(args, time.Since(start))...) + } else { + log.Printf(format+" == %v (%s)", append(args, err, time.Since(start))...) + } + return err + case <-time.After(1 * time.Second): + log.Printf(format, args...) + err := <-errChan + if err == nil { + log.Printf(format+" (%s)", append(args, time.Since(start))...) + } else { + log.Printf(format+" == %v (%s)", append(args, err, time.Since(start))...) + } + return err + } +} diff --git a/v2/internal/chezmoi/debugsystem_test.go b/v2/internal/chezmoi/debugsystem_test.go new file mode 100644 index 000000000000..7dae1a6caf86 --- /dev/null +++ b/v2/internal/chezmoi/debugsystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &DebugSystem{} diff --git a/v2/internal/chezmoi/deststateentry.go b/v2/internal/chezmoi/deststateentry.go new file mode 100644 index 000000000000..525f3d443d3b --- /dev/null +++ b/v2/internal/chezmoi/deststateentry.go @@ -0,0 +1,120 @@ +package chezmoi + +import ( + "os" +) + +// An DestStateEntry represents the state of an entry in the destination state. +type DestStateEntry interface { + Path() string + Remove(s System) error +} + +// A DestStateAbsent represents the absence of an entry in the destination +// state. +type DestStateAbsent struct { + path string +} + +// A DestStateDir represents the state of a directory in the destination state. +type DestStateDir struct { + path string + perm os.FileMode +} + +// A DestStateFile represents the state of a file in the destination state. +type DestStateFile struct { + path string + perm os.FileMode + *lazyContents +} + +// A DestStateSymlink represents the state of a symlink in the destination state. +type DestStateSymlink struct { + path string + *lazyLinkname +} + +// NewDestStateEntry returns a new DestStateEntry populated with path from fs. +func NewDestStateEntry(sr SystemReader, path string) (DestStateEntry, error) { + info, err := sr.Lstat(path) + switch { + case os.IsNotExist(err): + return &DestStateAbsent{ + path: path, + }, nil + case err != nil: + return nil, err + } + switch info.Mode() & os.ModeType { + case 0: + return &DestStateFile{ + path: path, + perm: info.Mode() & os.ModePerm, + lazyContents: &lazyContents{ + contentsFunc: func() ([]byte, error) { + return sr.ReadFile(path) + }, + }, + }, nil + case os.ModeDir: + return &DestStateDir{ + path: path, + perm: info.Mode() & os.ModePerm, + }, nil + case os.ModeSymlink: + return &DestStateSymlink{ + path: path, + lazyLinkname: &lazyLinkname{ + linknameFunc: func() (string, error) { + return sr.Readlink(path) + }, + }, + }, nil + default: + return nil, &unsupportedFileTypeError{ + path: path, + mode: info.Mode(), + } + } +} + +// Path returns d's path. +func (d *DestStateAbsent) Path() string { + return d.path +} + +// Remove removes d. +func (d *DestStateAbsent) Remove(s System) error { + return nil +} + +// Path returns d's path. +func (d *DestStateDir) Path() string { + return d.path +} + +// Remove removes d. +func (d *DestStateDir) Remove(s System) error { + return s.RemoveAll(d.path) +} + +// Path returns d's path. +func (d *DestStateFile) Path() string { + return d.path +} + +// Remove removes d. +func (d *DestStateFile) Remove(s System) error { + return s.RemoveAll(d.path) +} + +// Path returns d's path. +func (d *DestStateSymlink) Path() string { + return d.path +} + +// Remove removes d. +func (d *DestStateSymlink) Remove(s System) error { + return s.RemoveAll(d.path) +} diff --git a/v2/internal/chezmoi/dryrunsystem.go b/v2/internal/chezmoi/dryrunsystem.go new file mode 100644 index 000000000000..2ad6f622454a --- /dev/null +++ b/v2/internal/chezmoi/dryrunsystem.go @@ -0,0 +1,104 @@ +package chezmoi + +import ( + "os" + "os/exec" +) + +// DryRunSystem is an System that reads from, but does not write to, to +// a wrapped System. +type DryRunSystem struct { + s System +} + +// NewDryRunSystem returns a new DryRunSystem that wraps fs. +func NewDryRunSystem(s System) *DryRunSystem { + return &DryRunSystem{ + s: s, + } +} + +// Chmod implements System.Chmod. +func (s *DryRunSystem) Chmod(name string, mode os.FileMode) error { + return nil +} + +// Delete implements System.Delete. +func (s *DryRunSystem) Delete(bucket, key []byte) error { + return nil +} + +// Get implements System.Get. +func (s *DryRunSystem) Get(bucket, key []byte) ([]byte, error) { + return s.s.Get(bucket, key) +} + +// Glob implements System.Glob. +func (s *DryRunSystem) Glob(pattern string) ([]string, error) { + return s.s.Glob(pattern) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *DryRunSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return s.s.IdempotentCmdOutput(cmd) +} + +// Lstat implements System.Lstat. +func (s *DryRunSystem) Lstat(name string) (os.FileInfo, error) { + return s.s.Stat(name) +} + +// Mkdir implements System.Mkdir. +func (s *DryRunSystem) Mkdir(name string, perm os.FileMode) error { + return nil +} + +// ReadDir implements System.ReadDir. +func (s *DryRunSystem) ReadDir(dirname string) ([]os.FileInfo, error) { + return s.s.ReadDir(dirname) +} + +// ReadFile implements System.ReadFile. +func (s *DryRunSystem) ReadFile(filename string) ([]byte, error) { + return s.s.ReadFile(filename) +} + +// Readlink implements System.Readlink. +func (s *DryRunSystem) Readlink(name string) (string, error) { + return s.s.Readlink(name) +} + +// RemoveAll implements System.RemoveAll. +func (s *DryRunSystem) RemoveAll(string) error { + return nil +} + +// Rename implements System.Rename. +func (s *DryRunSystem) Rename(oldpath, newpath string) error { + return nil +} + +// RunScript implements System.RunScript. +func (s *DryRunSystem) RunScript(scriptname string, data []byte) error { + return nil +} + +// Set implements System.Set. +func (s *DryRunSystem) Set(bucket, key, value []byte) error { + return nil +} + +// Stat implements System.Stat. +func (s *DryRunSystem) Stat(name string) (os.FileInfo, error) { + return s.s.Stat(name) +} + +// WriteFile implements System.WriteFile. +func (s *DryRunSystem) WriteFile(string, []byte, os.FileMode) error { + return nil +} + +// WriteSymlink implements System.WriteSymlink. +func (s *DryRunSystem) WriteSymlink(string, string) error { + return nil +} diff --git a/v2/internal/chezmoi/dryrunsystem_test.go b/v2/internal/chezmoi/dryrunsystem_test.go new file mode 100644 index 000000000000..1d9eceff53b7 --- /dev/null +++ b/v2/internal/chezmoi/dryrunsystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &DryRunSystem{} diff --git a/v2/internal/chezmoi/encryptiontool.go b/v2/internal/chezmoi/encryptiontool.go new file mode 100644 index 000000000000..5a569ff3fed2 --- /dev/null +++ b/v2/internal/chezmoi/encryptiontool.go @@ -0,0 +1,12 @@ +package chezmoi + +// A CleanupFunc cleans up. +type CleanupFunc func() error + +// An EncryptionTool encrypts and decrypts data. +type EncryptionTool interface { + Decrypt(filenameHint string, ciphertext []byte) ([]byte, error) + DecryptToFile(filenameHint string, ciphertext []byte) (string, CleanupFunc, error) + Encrypt(plaintext []byte) ([]byte, error) + EncryptFile(filename string) ([]byte, error) +} diff --git a/v2/internal/chezmoi/encryptiontool_test.go b/v2/internal/chezmoi/encryptiontool_test.go new file mode 100644 index 000000000000..53069b2997ce --- /dev/null +++ b/v2/internal/chezmoi/encryptiontool_test.go @@ -0,0 +1,145 @@ +package chezmoi + +import ( + "io/ioutil" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/multierr" +) + +type testEncryptionTool struct { + key byte +} + +var _ EncryptionTool = &testEncryptionTool{} + +type testEncryptionToolOption func(*testEncryptionTool) + +func newTestEncryptionTool(options ...testEncryptionToolOption) *testEncryptionTool { + t := &testEncryptionTool{ + //nolint:gosec + key: byte(rand.Int() + 1), + } + for _, option := range options { + option(t) + } + return t +} + +func (t *testEncryptionTool) Decrypt(filenameHint string, ciphertext []byte) ([]byte, error) { + return t.xorWithKey(ciphertext), nil +} + +func (t *testEncryptionTool) DecryptToFile(filenameHint string, ciphertext []byte) (filename string, cleanupFunc CleanupFunc, err error) { + tempDir, err := ioutil.TempDir("", "chezmoi-test-decrypt") + if err != nil { + return + } + cleanupFunc = func() error { + return os.RemoveAll(tempDir) + } + + filename = filepath.Join(tempDir, filepath.Base(filenameHint)) + if err = ioutil.WriteFile(filename, t.xorWithKey(ciphertext), 0o600); err != nil { + err = multierr.Append(err, cleanupFunc()) + return + } + + return +} + +func (t *testEncryptionTool) Encrypt(plaintext []byte) ([]byte, error) { + return t.xorWithKey(plaintext), nil +} + +func (t *testEncryptionTool) EncryptFile(filename string) ([]byte, error) { + plaintext, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + return t.xorWithKey(plaintext), nil +} + +func (t *testEncryptionTool) xorWithKey(input []byte) []byte { + output := make([]byte, 0, len(input)) + for _, b := range input { + output = append(output, b^t.key) + } + return output +} + +func testEncryptionToolDecryptToFile(t *testing.T, et EncryptionTool) { + t.Run("DecryptToFile", func(t *testing.T) { + expectedPlaintext := []byte("secret") + + actualCiphertext, err := et.Encrypt(expectedPlaintext) + require.NoError(t, err) + assert.NotEqual(t, expectedPlaintext, actualCiphertext) + + filenameHint := "filename.txt" + filename, cleanup, err := et.DecryptToFile(filenameHint, actualCiphertext) + require.NoError(t, err) + assert.True(t, strings.Contains(filename, filenameHint)) + assert.NotNil(t, cleanup) + defer func() { + assert.NoError(t, cleanup()) + }() + + actualPlaintext, err := ioutil.ReadFile(filename) + require.NoError(t, err) + assert.Equal(t, expectedPlaintext, actualPlaintext) + }) +} + +func testEncryptionToolEncryptDecrypt(t *testing.T, et EncryptionTool) { + t.Run("EncryptDecrypt", func(t *testing.T) { + expectedPlaintext := []byte("secret") + + actualCiphertext, err := et.Encrypt(expectedPlaintext) + require.NoError(t, err) + assert.NotEqual(t, expectedPlaintext, actualCiphertext) + + actualPlaintext, err := et.Decrypt("", actualCiphertext) + require.NoError(t, err) + assert.Equal(t, expectedPlaintext, actualPlaintext) + }) +} + +func testEncryptionToolEncryptFile(t *testing.T, et EncryptionTool) { + t.Run("EncryptFile", func(t *testing.T) { + expectedPlaintext := []byte("secret") + + tempFile, err := ioutil.TempFile("", "chezmoi-test-encryption-tool") + require.NoError(t, err) + defer func() { + assert.NoError(t, os.RemoveAll(tempFile.Name())) + }() + if posixFileModes { + require.NoError(t, tempFile.Chmod(0o600)) + } + _, err = tempFile.Write(expectedPlaintext) + require.NoError(t, err) + require.NoError(t, tempFile.Close()) + + actualCiphertext, err := et.EncryptFile(tempFile.Name()) + require.NoError(t, err) + assert.NotEqual(t, expectedPlaintext, actualCiphertext) + + actualPlaintext, err := et.Decrypt("", actualCiphertext) + require.NoError(t, err) + assert.Equal(t, expectedPlaintext, actualPlaintext) + }) +} + +func TestTestEncruptionTool(t *testing.T) { + et := newTestEncryptionTool() + testEncryptionToolDecryptToFile(t, et) + testEncryptionToolEncryptDecrypt(t, et) + testEncryptionToolEncryptFile(t, et) +} diff --git a/v2/internal/chezmoi/errors.go b/v2/internal/chezmoi/errors.go new file mode 100644 index 000000000000..928677f3a039 --- /dev/null +++ b/v2/internal/chezmoi/errors.go @@ -0,0 +1,25 @@ +package chezmoi + +import ( + "fmt" + "os" + "strings" +) + +type duplicateTargetError struct { + targetName string + sourcePaths []string +} + +func (e *duplicateTargetError) Error() string { + return fmt.Sprintf("%s: duplicate target (%s)", e.targetName, strings.Join(e.sourcePaths, ", ")) +} + +type unsupportedFileTypeError struct { + path string + mode os.FileMode +} + +func (e *unsupportedFileTypeError) Error() string { + return fmt.Sprintf("%s: unsupported file type %s", e.path, modeTypeName(e.mode)) +} diff --git a/v2/internal/chezmoi/gitdiffsystem.go b/v2/internal/chezmoi/gitdiffsystem.go new file mode 100644 index 000000000000..0dfb8857b20d --- /dev/null +++ b/v2/internal/chezmoi/gitdiffsystem.go @@ -0,0 +1,339 @@ +package chezmoi + +import ( + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/format/diff" + "github.com/sergi/go-diff/diffmatchpatch" +) + +// A GitDiffSystem wraps a SystemReader and logs all of the actions it +// would execute as a git diff. +type GitDiffSystem struct { + sr SystemReader + prefix string + unifiedEncoder *diff.UnifiedEncoder +} + +// NewGitDiffSystem returns a new GitDiffSystem. +func NewGitDiffSystem(unifiedEncoder *diff.UnifiedEncoder, sr SystemReader, prefix string) *GitDiffSystem { + return &GitDiffSystem{ + sr: sr, + prefix: prefix, + unifiedEncoder: unifiedEncoder, + } +} + +// Chmod implements System.Chmod. +func (s *GitDiffSystem) Chmod(name string, mode os.FileMode) error { + fromFileMode, info, err := s.getFileMode(name) + if err != nil { + return err + } + // Assume that we're only changing permissions. + toFileMode, err := filemode.NewFromOSFileMode(info.Mode()&^os.ModePerm | mode) + if err != nil { + return err + } + path := s.trimPrefix(name) + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + from: &gitDiffFile{ + fileMode: fromFileMode, + path: path, + hash: plumbing.ZeroHash, + }, + to: &gitDiffFile{ + fileMode: toFileMode, + path: path, + hash: plumbing.ZeroHash, + }, + }, + }, + }) +} + +// Delete implements System.Delete. +func (s *GitDiffSystem) Delete(bucket, key []byte) error { + return nil +} + +// Get implements System.Get. +func (s *GitDiffSystem) Get(bucket, key []byte) ([]byte, error) { + return s.sr.Get(bucket, key) +} + +// Glob implements System.Glob. +func (s *GitDiffSystem) Glob(pattern string) ([]string, error) { + return s.sr.Glob(pattern) +} + +// Lstat implements System.Lstat. +func (s *GitDiffSystem) Lstat(name string) (os.FileInfo, error) { + return s.sr.Lstat(name) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *GitDiffSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return s.sr.IdempotentCmdOutput(cmd) +} + +// Mkdir implements System.Mkdir. +func (s *GitDiffSystem) Mkdir(name string, perm os.FileMode) error { + toFileMode, err := filemode.NewFromOSFileMode(os.ModeDir | perm) + if err != nil { + return err + } + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + to: &gitDiffFile{ + fileMode: toFileMode, + path: s.trimPrefix(name), + hash: plumbing.ZeroHash, + }, + }, + }, + }) +} + +// ReadDir implements System.ReadDir. +func (s *GitDiffSystem) ReadDir(dirname string) ([]os.FileInfo, error) { + return s.sr.ReadDir(dirname) +} + +// ReadFile implements System.ReadFile. +func (s *GitDiffSystem) ReadFile(filename string) ([]byte, error) { + return s.sr.ReadFile(filename) +} + +// Readlink implements System.Readlink. +func (s *GitDiffSystem) Readlink(name string) (string, error) { + return s.sr.Readlink(name) +} + +// RemoveAll implements System.RemoveAll. +func (s *GitDiffSystem) RemoveAll(name string) error { + fromFileMode, _, err := s.getFileMode(name) + if err != nil && !os.IsNotExist(err) { + return err + } + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + from: &gitDiffFile{ + fileMode: fromFileMode, + path: s.trimPrefix(name), + hash: plumbing.ZeroHash, + }, + }, + }, + }) +} + +// RunScript implements System.RunScript. +func (s *GitDiffSystem) RunScript(scriptname string, data []byte) error { + isBinary := isBinary(data) + var chunks []diff.Chunk + if !isBinary { + chunk := &gitDiffChunk{ + content: string(data), + operation: diff.Add, + } + chunks = append(chunks, chunk) + } + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + isBinary: isBinary, + to: &gitDiffFile{ + fileMode: filemode.Executable, + path: s.trimPrefix(scriptname), + hash: plumbing.ComputeHash(plumbing.BlobObject, data), + }, + chunks: chunks, + }, + }, + }) +} + +// Stat implements System.Stat. +func (s *GitDiffSystem) Stat(name string) (os.FileInfo, error) { + return s.sr.Stat(name) +} + +// Rename implements System.Rename. +func (s *GitDiffSystem) Rename(oldpath, newpath string) error { + fileMode, _, err := s.getFileMode(oldpath) + if err != nil { + return err + } + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + from: &gitDiffFile{ + fileMode: fileMode, + path: s.trimPrefix(oldpath), + hash: plumbing.ZeroHash, + }, + to: &gitDiffFile{ + fileMode: fileMode, + path: s.trimPrefix(newpath), + hash: plumbing.ZeroHash, + }, + }, + }, + }) +} + +// Set implements System.Set. +func (s *GitDiffSystem) Set(bucket, key, value []byte) error { + return nil +} + +// WriteFile implements System.WriteFile. +func (s *GitDiffSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { + fromFileMode, _, err := s.getFileMode(filename) + var fromData []byte + switch { + case err == nil: + fromData, err = s.sr.ReadFile(filename) + if err != nil { + return err + } + case os.IsNotExist(err): + default: + return err + } + toFileMode, err := filemode.NewFromOSFileMode(perm) + if err != nil { + return err + } + path := s.trimPrefix(filename) + isBinary := isBinary(fromData) || isBinary(data) + var chunks []diff.Chunk + if !isBinary { + chunks = diffChunks(string(fromData), string(data)) + } + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + isBinary: isBinary, + from: &gitDiffFile{ + fileMode: fromFileMode, + path: path, + hash: plumbing.ComputeHash(plumbing.BlobObject, fromData), + }, + to: &gitDiffFile{ + fileMode: toFileMode, + path: path, + hash: plumbing.ComputeHash(plumbing.BlobObject, data), + }, + chunks: chunks, + }, + }, + }) +} + +// WriteSymlink implements System.WriteSymlink. +func (s *GitDiffSystem) WriteSymlink(oldname, newname string) error { + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + to: &gitDiffFile{ + fileMode: filemode.Symlink, + path: s.trimPrefix(newname), + hash: plumbing.ComputeHash(plumbing.BlobObject, []byte(oldname)), + }, + chunks: []diff.Chunk{ + &gitDiffChunk{ + content: oldname, + operation: diff.Add, + }, + }, + }, + }, + }) +} + +func (s *GitDiffSystem) getFileMode(name string) (filemode.FileMode, os.FileInfo, error) { + info, err := s.sr.Stat(name) + if err != nil { + return filemode.Empty, nil, err + } + fileMode, err := filemode.NewFromOSFileMode(info.Mode()) + return fileMode, info, err +} + +func (s *GitDiffSystem) trimPrefix(path string) string { + return strings.TrimPrefix(path, s.prefix) +} + +var gitDiffOperation = map[diffmatchpatch.Operation]diff.Operation{ + diffmatchpatch.DiffDelete: diff.Delete, + diffmatchpatch.DiffEqual: diff.Equal, + diffmatchpatch.DiffInsert: diff.Add, +} + +type gitDiffChunk struct { + content string + operation diff.Operation +} + +func (c *gitDiffChunk) Content() string { return c.content } +func (c *gitDiffChunk) Type() diff.Operation { return c.operation } + +type gitDiffFile struct { + hash plumbing.Hash + fileMode filemode.FileMode + path string +} + +func (f *gitDiffFile) Hash() plumbing.Hash { return f.hash } +func (f *gitDiffFile) Mode() filemode.FileMode { return f.fileMode } +func (f *gitDiffFile) Path() string { return f.path } + +type gitDiffFilePatch struct { + isBinary bool + from, to diff.File + chunks []diff.Chunk +} + +func (fp *gitDiffFilePatch) IsBinary() bool { return fp.isBinary } +func (fp *gitDiffFilePatch) Files() (diff.File, diff.File) { return fp.from, fp.to } +func (fp *gitDiffFilePatch) Chunks() []diff.Chunk { return fp.chunks } + +type gitDiffPatch struct { + filePatches []diff.FilePatch + message string +} + +func (p *gitDiffPatch) FilePatches() []diff.FilePatch { return p.filePatches } +func (p *gitDiffPatch) Message() string { return p.message } + +func diffChunks(from, to string) []diff.Chunk { + dmp := diffmatchpatch.New() + dmp.DiffTimeout = time.Second + fromRunes, toRunes, runesToLines := dmp.DiffLinesToRunes(from, to) + diffs := dmp.DiffCharsToLines(dmp.DiffMainRunes(fromRunes, toRunes, false), runesToLines) + chunks := make([]diff.Chunk, 0, len(diffs)) + for _, d := range diffs { + chunk := &gitDiffChunk{ + content: d.Text, + operation: gitDiffOperation[d.Type], + } + chunks = append(chunks, chunk) + } + return chunks +} + +func isBinary(data []byte) bool { + return len(data) != 0 && !strings.HasPrefix(http.DetectContentType(data), "text/") +} diff --git a/v2/internal/chezmoi/gitdiffsystem_test.go b/v2/internal/chezmoi/gitdiffsystem_test.go new file mode 100644 index 000000000000..d0a58b21e141 --- /dev/null +++ b/v2/internal/chezmoi/gitdiffsystem_test.go @@ -0,0 +1,11 @@ +package chezmoi + +import "github.com/go-git/go-git/v5/plumbing/format/diff" + +var ( + _ System = &GitDiffSystem{} + _ diff.Chunk = &gitDiffChunk{} + _ diff.File = &gitDiffFile{} + _ diff.FilePatch = &gitDiffFilePatch{} + _ diff.Patch = &gitDiffPatch{} +) diff --git a/v2/internal/chezmoi/gpgencryptiontool.go b/v2/internal/chezmoi/gpgencryptiontool.go new file mode 100644 index 000000000000..94f4be69bdd8 --- /dev/null +++ b/v2/internal/chezmoi/gpgencryptiontool.go @@ -0,0 +1,133 @@ +package chezmoi + +// FIXME document gpg.args + +import ( + "io/ioutil" + "os" + "os/exec" + "path/filepath" + + "go.uber.org/multierr" +) + +// A GPGEncryptionTool uses gpg for encryption and decryption. +type GPGEncryptionTool struct { + Command string + Args []string + Recipient string + Symmetric bool +} + +// Decrypt implements EncyptionTool.Decrypt. +func (t *GPGEncryptionTool) Decrypt(filenameHint string, ciphertext []byte) (plaintext []byte, err error) { + filename, cleanup, err := t.DecryptToFile(filenameHint, ciphertext) + if err != nil { + return + } + defer func() { + err = multierr.Append(err, cleanup()) + }() + return ioutil.ReadFile(filename) +} + +// DecryptToFile implements EncyptionTool.DecryptToFile. +func (t *GPGEncryptionTool) DecryptToFile(filenameHint string, ciphertext []byte) (filename string, cleanupFunc CleanupFunc, err error) { + tempDir, err := ioutil.TempDir("", "chezmoi-gpg-decrypt") + if err != nil { + return + } + cleanupFunc = func() error { + return os.RemoveAll(tempDir) + } + + filename = filepath.Join(tempDir, filepath.Base(filenameHint)) + inputFilename := filename + ".gpg" + if err = ioutil.WriteFile(inputFilename, ciphertext, 0o600); err != nil { + err = multierr.Append(err, cleanupFunc()) + return + } + + args := []string{ + "--armor", + "--decrypt", + "--output", filename, + "--quiet", + } + if t.Symmetric { + args = append(args, "--symmetric") + } + args = append(args, inputFilename) + + if err = t.runWithArgs(args); err != nil { + err = multierr.Append(err, cleanupFunc()) + return + } + + return +} + +// Encrypt implements EncryptionTool.Encypt. +func (t *GPGEncryptionTool) Encrypt(plaintext []byte) (ciphertext []byte, err error) { + tempFile, err := ioutil.TempFile("", "chezmoi-gpg-encrypt") + if err != nil { + return + } + defer func() { + err = multierr.Append(err, os.RemoveAll(tempFile.Name())) + }() + + if posixFileModes { + if err = tempFile.Chmod(0o600); err != nil { + return + } + } + + if err = ioutil.WriteFile(tempFile.Name(), ciphertext, 0o600); err != nil { + return + } + + return t.EncryptFile(tempFile.Name()) +} + +// EncryptFile implements EncryptionTool.EncryptFile. +func (t *GPGEncryptionTool) EncryptFile(filename string) (ciphertext []byte, err error) { + tempDir, err := ioutil.TempDir("", "chezmoi-gpg-encrypt") + if err != nil { + return + } + defer func() { + err = multierr.Append(err, os.RemoveAll(tempDir)) + }() + + outputFilename := filepath.Join(tempDir, filepath.Base(filename)+".gpg") + args := []string{ + "--armor", + "--encrypt", + "--output", outputFilename, + "--quiet", + } + switch { + case t.Symmetric: + args = append(args, "--symmetric") + case t.Recipient != "": + args = append(args, "--recipient", t.Recipient) + } + args = append(args, filename) + + if err = t.runWithArgs(args); err != nil { + return + } + + ciphertext, err = ioutil.ReadFile(outputFilename) + return +} + +func (t *GPGEncryptionTool) runWithArgs(args []string) error { + //nolint:gosec + cmd := exec.Command(t.Command, append(t.Args, args...)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/v2/internal/chezmoi/gpgencryptiontool_test.go b/v2/internal/chezmoi/gpgencryptiontool_test.go new file mode 100644 index 000000000000..15ab45d21718 --- /dev/null +++ b/v2/internal/chezmoi/gpgencryptiontool_test.go @@ -0,0 +1,49 @@ +package chezmoi + +// FIXME fix integration test and code +// FIXME add integration build flag + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var _ EncryptionTool = &GPGEncryptionTool{} + +func TestGPGEncryptionTool(t *testing.T) { + t.Skip("broken test") + + tempDir, err := ioutil.TempDir("", "chezmoi-test-gpg-encryption-tool") + require.NoError(t, err) + defer func() { + assert.NoError(t, os.RemoveAll(tempDir)) + }() + + if posixFileModes { + require.NoError(t, os.Chmod(tempDir, 0o700)) + } + + et := &GPGEncryptionTool{ + Command: "gpg", + Args: []string{ + "--batch", + "--homedir", tempDir, + "--passphrase", "passphrase", + "--pinentry-mode", "loopback", + "--no-tty", + "--yes", + }, + Symmetric: true, + } + require.NoError(t, et.runWithArgs([]string{ + "--quick-generate-key", "chezmoi", + })) + + testEncryptionToolDecryptToFile(t, et) + testEncryptionToolEncryptDecrypt(t, et) + testEncryptionToolEncryptFile(t, et) +} diff --git a/v2/internal/chezmoi/jsonserializationformat.go b/v2/internal/chezmoi/jsonserializationformat.go new file mode 100644 index 000000000000..823969133bfd --- /dev/null +++ b/v2/internal/chezmoi/jsonserializationformat.go @@ -0,0 +1,33 @@ +package chezmoi + +import ( + "encoding/json" + "strings" +) + +type jsonSerializationFormat struct{} + +// JSONSerializationFormat is the JSON serialization format. +var JSONSerializationFormat jsonSerializationFormat + +func (jsonSerializationFormat) Name() string { + return "json" +} + +func (jsonSerializationFormat) Serialize(data interface{}) ([]byte, error) { + sb := &strings.Builder{} + e := json.NewEncoder(sb) + e.SetIndent("", " ") + if err := e.Encode(data); err != nil { + return nil, err + } + return []byte(sb.String()), nil +} + +func (jsonSerializationFormat) Deserialize(data []byte) (interface{}, error) { + var result interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} diff --git a/v2/internal/chezmoi/jsonserializationformat_test.go b/v2/internal/chezmoi/jsonserializationformat_test.go new file mode 100644 index 000000000000..8b4e61160555 --- /dev/null +++ b/v2/internal/chezmoi/jsonserializationformat_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ SerializationFormat = JSONSerializationFormat diff --git a/v2/internal/chezmoi/lazy.go b/v2/internal/chezmoi/lazy.go new file mode 100644 index 000000000000..17a2e23fa861 --- /dev/null +++ b/v2/internal/chezmoi/lazy.go @@ -0,0 +1,83 @@ +package chezmoi + +import "crypto/sha256" + +type contentsFunc func() ([]byte, error) + +// A lazyContents evaluates its contents lazily. +type lazyContents struct { + contentsFunc contentsFunc + contents []byte + contentsErr error + contentsSHA256 []byte +} + +type linknameFunc func() (string, error) + +// A lazyLinkname evaluates its linkname lazily. +type lazyLinkname struct { + linknameFunc linknameFunc + linkname string + linknameErr error +} + +// newLazyContents returns a new lazyContents with contents. +func newLazyContents(contents []byte) *lazyContents { + return &lazyContents{ + contents: contents, + } +} + +// Contents returns e's contents. +func (lc *lazyContents) Contents() ([]byte, error) { + if lc == nil { + return nil, nil + } + if lc.contentsFunc != nil { + lc.contents, lc.contentsErr = lc.contentsFunc() + lc.contentsFunc = nil + if lc.contentsErr == nil { + lc.contentsSHA256 = sha256Sum(lc.contents) + } + } + return lc.contents, lc.contentsErr +} + +// ContentsSHA256 returns the SHA256 sum of f's contents. +func (lc *lazyContents) ContentsSHA256() ([]byte, error) { + if lc == nil { + return sha256Sum(nil), nil + } + if lc.contentsSHA256 == nil { + contents, err := lc.Contents() + if err != nil { + return nil, err + } + lc.contentsSHA256 = sha256Sum(contents) + } + return lc.contentsSHA256, nil +} + +// newLazyLinkname returns a new lazyLinkname with linkname. +func newLazyLinkname(linkname string) *lazyLinkname { + return &lazyLinkname{ + linkname: linkname, + } +} + +// Linkname returns s's linkname. +func (ll *lazyLinkname) Linkname() (string, error) { + if ll == nil { + return "", nil + } + if ll.linknameFunc != nil { + ll.linkname, ll.linknameErr = ll.linknameFunc() + ll.linknameFunc = nil + } + return ll.linkname, ll.linknameErr +} + +func sha256Sum(data []byte) []byte { + sha256SumArr := sha256.Sum256(data) + return sha256SumArr[:] +} diff --git a/v2/internal/chezmoi/maybeshellquote.go b/v2/internal/chezmoi/maybeshellquote.go new file mode 100644 index 000000000000..6d527d16fe35 --- /dev/null +++ b/v2/internal/chezmoi/maybeshellquote.go @@ -0,0 +1,61 @@ +package chezmoi + +import ( + "regexp" + "strings" +) + +var needShellQuoteRegexp = regexp.MustCompile(`[^+\-./0-9=A-Z_a-z]`) + +const ( + backslash = '\\' + singleQuote = '\'' +) + +// MaybeShellQuote returns s quoted as a shell argument, if necessary. +func MaybeShellQuote(s string) string { + switch { + case s == "": + return "''" + case needShellQuoteRegexp.MatchString(s): + result := make([]byte, 0, 2+len(s)) + inSingleQuotes := false + for _, b := range []byte(s) { + switch b { + case backslash: + if !inSingleQuotes { + result = append(result, singleQuote) + inSingleQuotes = true + } + result = append(result, backslash, backslash) + case singleQuote: + if inSingleQuotes { + result = append(result, singleQuote) + inSingleQuotes = false + } + result = append(result, backslash, singleQuote) + default: + if !inSingleQuotes { + result = append(result, singleQuote) + inSingleQuotes = true + } + result = append(result, b) + } + } + if inSingleQuotes { + result = append(result, singleQuote) + } + return string(result) + default: + return s + } +} + +// ShellQuoteArgs returs args shell quoted and joined into a single string. +func ShellQuoteArgs(args []string) string { + shellQuotedArgs := make([]string, 0, len(args)) + for _, arg := range args { + shellQuotedArgs = append(shellQuotedArgs, MaybeShellQuote(arg)) + } + return strings.Join(shellQuotedArgs, " ") +} diff --git a/v2/internal/chezmoi/maybeshellquote_test.go b/v2/internal/chezmoi/maybeshellquote_test.go new file mode 100644 index 000000000000..cb145721cc00 --- /dev/null +++ b/v2/internal/chezmoi/maybeshellquote_test.go @@ -0,0 +1,26 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMaybeShellQuote(t *testing.T) { + for s, expected := range map[string]string{ + ``: `''`, + `'`: `\'`, + `''`: `\'\'`, + `'a'`: `\''a'\'`, + `\`: `'\\'`, + `\a`: `'\\a'`, + `$a`: `'$a'`, + `a`: `a`, + `a/b`: `a/b`, + `a b`: `'a b'`, + `--arg`: `--arg`, + `--arg=value`: `--arg=value`, + } { + assert.Equal(t, expected, MaybeShellQuote(s), "quoting %q", s) + } +} diff --git a/v2/internal/chezmoi/nullencryptiontool.go b/v2/internal/chezmoi/nullencryptiontool.go new file mode 100644 index 000000000000..23168f59e866 --- /dev/null +++ b/v2/internal/chezmoi/nullencryptiontool.go @@ -0,0 +1,25 @@ +package chezmoi + +import "io/ioutil" + +// A nullEncryptionTool returns its input unchanged. +type nullEncryptionTool struct{} + +func (*nullEncryptionTool) Decrypt(filenameHint string, ciphertext []byte) ([]byte, error) { + return ciphertext, nil +} + +func (*nullEncryptionTool) DecryptToFile(filenameHint string, ciphertext []byte) (string, CleanupFunc, error) { + return filenameHint, nullCleanupFunc, nil +} + +func (*nullEncryptionTool) Encrypt(plaintext []byte) ([]byte, error) { + return plaintext, nil +} + +func (*nullEncryptionTool) EncryptFile(filename string) ([]byte, error) { + return ioutil.ReadFile(filename) +} + +// nullCleanupFunc does nothing. +func nullCleanupFunc() error { return nil } diff --git a/v2/internal/chezmoi/nullencryptiontool_test.go b/v2/internal/chezmoi/nullencryptiontool_test.go new file mode 100644 index 000000000000..a69e5fc5200b --- /dev/null +++ b/v2/internal/chezmoi/nullencryptiontool_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ EncryptionTool = &nullEncryptionTool{} diff --git a/v2/internal/chezmoi/nullpersistentstate.go b/v2/internal/chezmoi/nullpersistentstate.go new file mode 100644 index 000000000000..3c2779917f8e --- /dev/null +++ b/v2/internal/chezmoi/nullpersistentstate.go @@ -0,0 +1,7 @@ +package chezmoi + +type nullPersistentState struct{} + +func (nullPersistentState) Delete(buckey, key []byte) error { return nil } +func (nullPersistentState) Get(bucket, key []byte) ([]byte, error) { return nil, nil } +func (nullPersistentState) Set(bucket, key, value []byte) error { return nil } diff --git a/v2/internal/chezmoi/nullsystem.go b/v2/internal/chezmoi/nullsystem.go new file mode 100644 index 000000000000..45a2e9648dfd --- /dev/null +++ b/v2/internal/chezmoi/nullsystem.go @@ -0,0 +1,26 @@ +package chezmoi + +import ( + "os" + "os/exec" +) + +// An nullSystem represents an null System. +type nullSystem struct { + nullPersistentState +} + +func (nullSystem) Chmod(name string, mode os.FileMode) error { return nil } +func (nullSystem) Glob(pattern string) ([]string, error) { return nil, nil } +func (nullSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { return cmd.Output() } +func (nullSystem) Lstat(name string) (os.FileInfo, error) { return nil, os.ErrNotExist } +func (nullSystem) Mkdir(dirname string, perm os.FileMode) error { return nil } +func (nullSystem) ReadDir(dirname string) ([]os.FileInfo, error) { return nil, os.ErrNotExist } +func (nullSystem) ReadFile(filename string) ([]byte, error) { return nil, os.ErrNotExist } +func (nullSystem) Readlink(name string) (string, error) { return "", os.ErrNotExist } +func (nullSystem) RemoveAll(name string) error { return nil } +func (nullSystem) Rename(oldpath, newpath string) error { return nil } +func (nullSystem) RunScript(scriptname string, data []byte) error { return nil } +func (nullSystem) Stat(name string) (os.FileInfo, error) { return nil, os.ErrNotExist } +func (nullSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { return nil } +func (nullSystem) WriteSymlink(oldname, newname string) error { return nil } diff --git a/v2/internal/chezmoi/nullsystem_test.go b/v2/internal/chezmoi/nullsystem_test.go new file mode 100644 index 000000000000..826f3efd3e96 --- /dev/null +++ b/v2/internal/chezmoi/nullsystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = nullSystem{} diff --git a/v2/internal/chezmoi/patternset.go b/v2/internal/chezmoi/patternset.go new file mode 100644 index 000000000000..8037c9f9b9a9 --- /dev/null +++ b/v2/internal/chezmoi/patternset.go @@ -0,0 +1,52 @@ +package chezmoi + +import "github.com/bmatcuk/doublestar" + +// An PatternSet is a set of patterns. +type PatternSet struct { + includes StringSet + excludes StringSet +} + +// A PatternSetOption sets an option on a pattern set. +type PatternSetOption func(*PatternSet) + +// NewPatternSet returns a new PatternSet. +func NewPatternSet(options ...PatternSetOption) *PatternSet { + ps := &PatternSet{ + includes: NewStringSet(), + excludes: NewStringSet(), + } + for _, option := range options { + option(ps) + } + return ps +} + +// Add adds a pattern to ps. +func (ps *PatternSet) Add(pattern string, include bool) error { + if _, err := doublestar.Match(pattern, ""); err != nil { + return nil + } + if include { + ps.includes.Add(pattern) + } else { + ps.excludes.Add(pattern) + } + return nil +} + +// Match returns if name matches any pattern in ps. +func (ps *PatternSet) Match(name string) bool { + for pattern := range ps.excludes { + if ok, _ := doublestar.Match(pattern, name); ok { + return false + } + } + for pattern := range ps.includes { + if ok, _ := doublestar.Match(pattern, name); ok { + return true + } + } + return false +} diff --git a/v2/internal/chezmoi/patternset_test.go b/v2/internal/chezmoi/patternset_test.go new file mode 100644 index 000000000000..9d47ee31be0f --- /dev/null +++ b/v2/internal/chezmoi/patternset_test.go @@ -0,0 +1,88 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPatternSet(t *testing.T) { + for _, tc := range []struct { + name string + ps *PatternSet + expectMatches map[string]bool + }{ + { + name: "empty", + ps: NewPatternSet(), + expectMatches: map[string]bool{ + "foo": false, + }, + }, + { + name: "exact", + ps: mustNewPatternSet(t, map[string]bool{ + "foo": true, + }), + expectMatches: map[string]bool{ + "foo": true, + "bar": false, + }, + }, + { + name: "wildcard", + ps: mustNewPatternSet(t, map[string]bool{ + "b*": true, + }), + expectMatches: map[string]bool{ + "foo": false, + "bar": true, + "baz": true, + }, + }, + { + name: "exclude", + ps: mustNewPatternSet(t, map[string]bool{ + "b*": true, + "baz": false, + }), + expectMatches: map[string]bool{ + "foo": false, + "bar": true, + "baz": false, + }, + }, + { + name: "doublestar", + ps: mustNewPatternSet(t, map[string]bool{ + "**/foo": true, + }), + expectMatches: map[string]bool{ + "foo": true, + "bar/foo": true, + "baz/bar/foo": true, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + for s, expectMatch := range tc.expectMatches { + assert.Equal(t, expectMatch, tc.ps.Match(s)) + } + }) + } +} + +func mustNewPatternSet(t *testing.T, patterns map[string]bool) *PatternSet { + ps := NewPatternSet() + for pattern, exclude := range patterns { + require.NoError(t, ps.Add(pattern, exclude)) + } + return ps +} + +func withAdd(t *testing.T, pattern string, include bool) PatternSetOption { + return func(ps *PatternSet) { + require.NoError(t, ps.Add(pattern, include)) + } +} diff --git a/v2/internal/chezmoi/persistentstate.go b/v2/internal/chezmoi/persistentstate.go new file mode 100644 index 000000000000..3cff25f0ac01 --- /dev/null +++ b/v2/internal/chezmoi/persistentstate.go @@ -0,0 +1,21 @@ +package chezmoi + +// FIXME should not need both PersistentState and PersistentStateWriter +// FIXME PersistentState should be just PersistentStateReader + Delete + Set + +// A PersistentStateReader reads a persistent state. +type PersistentStateReader interface { + Get(bucket, key []byte) ([]byte, error) +} + +// A PersistentStateWriter writes to a persistent state. +type PersistentStateWriter interface { + Delete(bucket, key []byte) error + Set(bucket, key, value []byte) error +} + +// A PersistentState is a persistent state. +type PersistentState interface { + PersistentStateReader + PersistentStateWriter +} diff --git a/v2/internal/chezmoi/persistentstate_test.go b/v2/internal/chezmoi/persistentstate_test.go new file mode 100644 index 000000000000..9035dc87ca55 --- /dev/null +++ b/v2/internal/chezmoi/persistentstate_test.go @@ -0,0 +1,34 @@ +package chezmoi + +type testPersistentState map[string]map[string][]byte + +func newTestPersistentState() testPersistentState { + return make(testPersistentState) +} + +func (s testPersistentState) Delete(bucket, key []byte) error { + bucketMap, ok := s[string(bucket)] + if !ok { + return nil + } + delete(bucketMap, string(key)) + return nil +} + +func (s testPersistentState) Get(bucket, key []byte) ([]byte, error) { + bucketMap, ok := s[string(bucket)] + if !ok { + return nil, nil + } + return bucketMap[string(key)], nil +} + +func (s testPersistentState) Set(bucket, key, value []byte) error { + bucketMap, ok := s[string(bucket)] + if !ok { + bucketMap = make(map[string][]byte) + s[string(bucket)] = bucketMap + } + bucketMap[string(key)] = value + return nil +} diff --git a/v2/internal/chezmoi/realsystem.go b/v2/internal/chezmoi/realsystem.go new file mode 100644 index 000000000000..f451e18c3235 --- /dev/null +++ b/v2/internal/chezmoi/realsystem.go @@ -0,0 +1,140 @@ +package chezmoi + +import ( + "io/ioutil" + "os" + "os/exec" + "path" + "path/filepath" + + "github.com/bmatcuk/doublestar" + "github.com/google/renameio" + vfs "github.com/twpayne/go-vfs" + "go.uber.org/multierr" +) + +// An RealSystem is a System that writes to a filesystem and executes scripts. +type RealSystem struct { + vfs.FS + PersistentState + devCache map[string]uint // devCache maps directories to device numbers. + tempDirCache map[uint]string // tempDir maps device numbers to renameio temporary directories. +} + +// NewRealSystem returns a System that acts on fs. +func NewRealSystem(fs vfs.FS, persistentState PersistentState) *RealSystem { + return &RealSystem{ + FS: fs, + PersistentState: persistentState, + devCache: make(map[string]uint), + tempDirCache: make(map[uint]string), + } +} + +// Chmod implements System.Glob. +func (s *RealSystem) Chmod(name string, mode os.FileMode) error { + if !posixFileModes { + return nil + } + return s.FS.Chmod(name, mode) +} + +// Glob implements System.Glob. +func (s *RealSystem) Glob(pattern string) ([]string, error) { + if os.PathSeparator == '/' { + return doublestar.GlobOS(s, pattern) + } + matches, err := doublestar.GlobOS(s, pattern) + if err != nil { + return nil, err + } + for i, match := range matches { + matches[i] = filepath.ToSlash(match) + } + return matches, nil +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *RealSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return cmd.Output() +} + +// PathSeparator implements doublestar.OS.PathSeparator. +func (s *RealSystem) PathSeparator() rune { + return pathSeparator +} + +// RunScript implements System.RunScript. +func (s *RealSystem) RunScript(scriptname string, data []byte) (err error) { + // Write the temporary script file. Put the randomness at the front of the + // filename to preserve any file extension for Windows scripts. + f, err := ioutil.TempFile("", "*."+path.Base(scriptname)) + if err != nil { + return + } + defer func() { + err = multierr.Append(err, os.RemoveAll(f.Name())) + }() + + // Make the script private before writing it in case it contains any + // secrets. + if posixFileModes { + if err = f.Chmod(0o700); err != nil { + return + } + } + _, err = f.Write(data) + err = multierr.Append(err, f.Close()) + if err != nil { + return + } + + // Run the temporary script file. + //nolint:gosec + c := exec.Command(f.Name()) + // c.Dir = path.Join(applyOptions.DestDir, filepath.Dir(s.targetName)) // FIXME + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + err = c.Run() + return +} + +// WriteSymlink implements System.WriteSymlink. +func (s *RealSystem) WriteSymlink(oldname, newname string) error { + // Special case: if writing to the real filesystem, use + // github.com/google/renameio. + if s.FS == vfs.OSFS { + return renameio.Symlink(oldname, newname) + } + if err := s.FS.RemoveAll(newname); err != nil && !os.IsNotExist(err) { + return err + } + return s.FS.Symlink(oldname, newname) +} + +// WriteFile is like ioutil.WriteFile but always sets perm before writing data. +// ioutil.WriteFile only sets the permissions when creating a new file. We need +// to ensure permissions, so we use our own implementation. +func WriteFile(fs vfs.FS, filename string, data []byte, perm os.FileMode) (err error) { + // Create a new file, or truncate any existing one. + f, err := fs.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return + } + defer func() { + err = multierr.Append(err, f.Close()) + }() + + // Set permissions after truncation but before writing any data, in case the + // file contained private data before, but before writing the new contents, + // in case the contents contain private data after. + if posixFileModes { + if err = f.Chmod(perm); err != nil { + return + } + } + + _, err = f.Write(data) + return err +} diff --git a/v2/internal/chezmoi/realsystem_posix.go b/v2/internal/chezmoi/realsystem_posix.go new file mode 100644 index 000000000000..9ca3b40f30b9 --- /dev/null +++ b/v2/internal/chezmoi/realsystem_posix.go @@ -0,0 +1,56 @@ +// +build !windows + +package chezmoi + +import ( + "errors" + "os" + "path" + "syscall" + + "github.com/google/renameio" + vfs "github.com/twpayne/go-vfs" +) + +// WriteFile implements System.WriteFile. +func (s *RealSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { + // Special case: if writing to the real filesystem on a non-Windows system, + // use github.com/google/renameio. + if s.FS == vfs.OSFS { + dir := path.Dir(filename) + dev, ok := s.devCache[dir] + if !ok { + info, err := s.Stat(dir) + if err != nil { + return err + } + statT, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("os.FileInfo.Sys() cannot be converted to a *syscall.Stat_t") + } + dev = uint(statT.Dev) + s.devCache[dir] = dev + } + tempDir, ok := s.tempDirCache[dev] + if !ok { + tempDir = renameio.TempDir(dir) + s.tempDirCache[dev] = tempDir + } + t, err := renameio.TempFile(tempDir, filename) + if err != nil { + return err + } + defer func() { + _ = t.Cleanup() + }() + if err := t.Chmod(perm); err != nil { + return err + } + if _, err := t.Write(data); err != nil { + return err + } + return t.CloseAtomicallyReplace() + } + + return WriteFile(s.FS, filename, data, perm) +} diff --git a/v2/internal/chezmoi/realsystem_test.go b/v2/internal/chezmoi/realsystem_test.go new file mode 100644 index 000000000000..df1160a774c3 --- /dev/null +++ b/v2/internal/chezmoi/realsystem_test.go @@ -0,0 +1,68 @@ +package chezmoi + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" +) + +var _ System = &RealSystem{} + +func TestRealSystemGlob(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{ + "/home/user": map[string]interface{}{ + "bar": "", + "baz": "", + "foo": "", + "dir/bar": "", + "dir/foo": "", + "dir/subdir/foo": "", + }, + }) + require.NoError(t, err) + defer cleanup() + + s := NewRealSystem(fs, newTestPersistentState()) + for _, tc := range []struct { + pattern string + expectedMatches []string + }{ + { + pattern: "/home/user/foo", + expectedMatches: []string{ + "/home/user/foo", + }, + }, + { + pattern: "/home/user/**/foo", + expectedMatches: []string{ + "/home/user/dir/foo", + "/home/user/dir/subdir/foo", + "/home/user/foo", + }, + }, + { + pattern: "/home/user/**/ba*", + expectedMatches: []string{ + "/home/user/bar", + "/home/user/baz", + "/home/user/dir/bar", + }, + }, + } { + t.Run(tc.pattern, func(t *testing.T) { + actualMatches, err := s.Glob(tc.pattern) + require.NoError(t, err) + sort.Strings(actualMatches) + assert.Equal(t, tc.expectedMatches, actualMatches) + }) + } +} + +func newTestRealSystem(fs vfs.FS) *RealSystem { + return NewRealSystem(fs, newTestPersistentState()) +} diff --git a/v2/internal/chezmoi/realsystem_windows.go b/v2/internal/chezmoi/realsystem_windows.go new file mode 100644 index 000000000000..68f67c3aa1c2 --- /dev/null +++ b/v2/internal/chezmoi/realsystem_windows.go @@ -0,0 +1,8 @@ +package chezmoi + +import "os" + +// WriteFile implements System.WriteFile. +func (s *RealSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { + return WriteFile(s.FS, filename, data, perm) +} diff --git a/v2/internal/chezmoi/serializationformat.go b/v2/internal/chezmoi/serializationformat.go new file mode 100644 index 000000000000..14c7ffe15207 --- /dev/null +++ b/v2/internal/chezmoi/serializationformat.go @@ -0,0 +1,8 @@ +package chezmoi + +// A SerializationFormat is a serialization format. +type SerializationFormat interface { + Deserialize(data []byte) (interface{}, error) + Name() string + Serialize(data interface{}) ([]byte, error) +} diff --git a/v2/internal/chezmoi/sourcestate.go b/v2/internal/chezmoi/sourcestate.go new file mode 100644 index 000000000000..8bebe1ff6605 --- /dev/null +++ b/v2/internal/chezmoi/sourcestate.go @@ -0,0 +1,545 @@ +package chezmoi + +import ( + "bufio" + "bytes" + "fmt" + "os" + "path" + "path/filepath" + "sort" + "strings" + "text/template" + + "github.com/coreos/go-semver/semver" + vfs "github.com/twpayne/go-vfs" + "go.uber.org/multierr" +) + +// A SourceState is a source state. +type SourceState struct { + Entries map[string]SourceStateEntry + s System + sourcePath string + umask os.FileMode + encryptionTool EncryptionTool + ignore *PatternSet + minVersion *semver.Version + remove *PatternSet + templateData interface{} + templateFuncs template.FuncMap + templateOptions []string + templates map[string]*template.Template +} + +// A SourceStateOption sets an option on a source state. +type SourceStateOption func(*SourceState) + +// WithEncryptionTool set the encryption tool. +func WithEncryptionTool(encryptionTool EncryptionTool) SourceStateOption { + return func(ss *SourceState) { + ss.encryptionTool = encryptionTool + } +} + +// WithSourcePath sets the source path. +func WithSourcePath(sourcePath string) SourceStateOption { + return func(ss *SourceState) { + ss.sourcePath = sourcePath + } +} + +// WithSystem sets the system. +func WithSystem(s System) SourceStateOption { + return func(ss *SourceState) { + ss.s = s + } +} + +// WithTemplateData sets the template data. +func WithTemplateData(templateData interface{}) SourceStateOption { + return func(ss *SourceState) { + ss.templateData = templateData + } +} + +// WithTemplateFuncs sets the template functions. +func WithTemplateFuncs(templateFuncs template.FuncMap) SourceStateOption { + return func(ss *SourceState) { + ss.templateFuncs = templateFuncs + } +} + +// WithTemplateOptions sets the template options. +func WithTemplateOptions(templateOptions []string) SourceStateOption { + return func(ss *SourceState) { + ss.templateOptions = templateOptions + } +} + +// WithUmask sets the umask. +func WithUmask(umask os.FileMode) SourceStateOption { + return func(ss *SourceState) { + ss.umask = umask + } +} + +// NewSourceState creates a new source state with the given options. +func NewSourceState(options ...SourceStateOption) *SourceState { + ss := &SourceState{ + Entries: make(map[string]SourceStateEntry), + umask: DefaultUmask, + encryptionTool: &nullEncryptionTool{}, + ignore: NewPatternSet(), + remove: NewPatternSet(), + templateOptions: DefaultTemplateOptions, + } + for _, option := range options { + option(ss) + } + return ss +} + +// Add adds sourceStateEntry to ss. +func (ss *SourceState) Add() error { + return nil // FIXME +} + +// ApplyAll updates targetDir in fs to match ss. +func (ss *SourceState) ApplyAll(s System, umask os.FileMode, targetDir string) error { + for _, targetName := range ss.sortedTargetNames() { + if err := ss.ApplyOne(s, umask, targetDir, targetName); err != nil { + return err + } + } + return nil +} + +// ApplyOne updates targetName in targetDir on fs to match ss using s. +func (ss *SourceState) ApplyOne(s System, umask os.FileMode, targetDir, targetName string) error { + targetPath := path.Join(targetDir, targetName) + destStateEntry, err := NewDestStateEntry(s, targetPath) + if err != nil { + return err + } + targetStateEntry, err := ss.Entries[targetName].TargetStateEntry() + if err != nil { + return err + } + if err := targetStateEntry.Apply(s, destStateEntry); err != nil { + return err + } + if targetStateDir, ok := targetStateEntry.(*TargetStateDir); ok { + if targetStateDir.exact { + infos, err := s.ReadDir(targetPath) + if err != nil { + return err + } + baseNames := make([]string, 0, len(infos)) + for _, info := range infos { + if baseName := info.Name(); baseName != "." && baseName != ".." { + baseNames = append(baseNames, baseName) + } + } + sort.Strings(baseNames) + for _, baseName := range baseNames { + if _, ok := ss.Entries[path.Join(targetName, baseName)]; !ok { + if err := s.RemoveAll(path.Join(targetPath, baseName)); err != nil { + return err + } + } + } + } + } + // FIXME chezmoiremove + return nil +} + +// ExecuteTemplateData returns the result of executing template data. +func (ss *SourceState) ExecuteTemplateData(name string, data []byte) ([]byte, error) { + tmpl, err := template.New(name).Option(ss.templateOptions...).Funcs(ss.templateFuncs).Parse(string(data)) + if err != nil { + return nil, err + } + for name, t := range ss.templates { + tmpl, err = tmpl.AddParseTree(name, t.Tree) + if err != nil { + return nil, err + } + } + output := &bytes.Buffer{} + if err = tmpl.ExecuteTemplate(output, name, ss.templateData); err != nil { + return nil, err + } + return output.Bytes(), nil +} + +// Read reads a source state from sourcePath in fs. +func (ss *SourceState) Read() error { + // Read all source entries. + allSourceEntries := make(map[string][]SourceStateEntry) + sourceDirPrefix := filepath.ToSlash(ss.sourcePath) + pathSeparatorStr + if err := vfs.Walk(ss.s, ss.sourcePath, func(sourcePath string, info os.FileInfo, err error) error { + sourcePath = filepath.ToSlash(sourcePath) + if err != nil { + return err + } + if sourcePath == ss.sourcePath { + return nil + } + relPath := strings.TrimPrefix(sourcePath, sourceDirPrefix) + sourceDirName, sourceName := path.Split(relPath) + targetDirName := getTargetDirName(sourceDirName) + switch { + case info.Name() == ignoreName: + return ss.addPatterns(ss.ignore, sourcePath, sourceDirName) + case info.Name() == removeName: + return ss.addPatterns(ss.remove, sourcePath, targetDirName) + case info.Name() == templatesDirName: + if err := ss.addTemplatesDir(sourcePath); err != nil { + return err + } + return filepath.SkipDir + case info.Name() == versionName: + return ss.addVersionFile(sourcePath) + case strings.HasPrefix(info.Name(), chezmoiPrefix): + // FIXME accumulate warning about unrecognized special file + fallthrough + case strings.HasPrefix(info.Name(), ignorePrefix): + if info.IsDir() { + return filepath.SkipDir + } + return nil + case info.IsDir(): + dirAttributes := ParseDirAttributes(sourceName) + targetName := path.Join(targetDirName, dirAttributes.Name) + if ss.ignore.Match(targetName) { + return nil + } + sourceEntry := ss.newSourceStateDir(sourcePath, dirAttributes) + allSourceEntries[targetName] = append(allSourceEntries[targetName], sourceEntry) + return nil + case info.Mode().IsRegular(): + fileAttributes := ParseFileAttributes(sourceName) + targetName := path.Join(targetDirName, fileAttributes.Name) + if ss.ignore.Match(targetName) { + return nil + } + sourceEntry := ss.newSourceStateFile(sourcePath, fileAttributes) + allSourceEntries[targetName] = append(allSourceEntries[targetName], sourceEntry) + return nil + default: + return &unsupportedFileTypeError{ + path: sourcePath, + mode: info.Mode(), + } + } + }); err != nil { + return err + } + + // Checking for duplicate source entries with the same target name. Iterate + // over the target names in order so that any error is deterministic. + var err error + targetNames := make([]string, 0, len(allSourceEntries)) + for targetName := range allSourceEntries { + targetNames = append(targetNames, targetName) + } + sort.Strings(targetNames) + for _, targetName := range targetNames { + sourceEntries := allSourceEntries[targetName] + if len(sourceEntries) == 1 { + continue + } + sourcePaths := make([]string, 0, len(sourceEntries)) + for _, sourceEntry := range sourceEntries { + sourcePaths = append(sourcePaths, sourceEntry.Path()) + } + err = multierr.Append(err, &duplicateTargetError{ + targetName: targetName, + sourcePaths: sourcePaths, + }) + } + if err != nil { + return err + } + + // Populate ss.sourceEntries with the unique source entry for each target. + for targetName, sourceEntries := range allSourceEntries { + ss.Entries[targetName] = sourceEntries[0] + } + return nil +} + +// Remove removes everything in targetDir that matches s's remove pattern set. +func (ss *SourceState) Remove(s System, targetDir string) error { + // Build a set of targets to remove. + targetDirPrefix := targetDir + pathSeparatorStr + targetPathsToRemove := NewStringSet() + for include := range ss.remove.includes { + matches, err := s.Glob(path.Join(targetDir, include)) + if err != nil { + return err + } + for _, match := range matches { + // Don't remove targets that are excluded from remove. + if !ss.remove.Match(strings.TrimPrefix(match, targetDirPrefix)) { + continue + } + targetPathsToRemove.Add(match) + } + } + + // Remove targets in order. Parent directories are removed before their + // children, which is okay because RemoveAll does not treat os.ErrNotExist + // as an error. + sortedTargetPathsToRemove := targetPathsToRemove.Elements() + sort.Strings(sortedTargetPathsToRemove) + for _, targetPath := range sortedTargetPathsToRemove { + if err := s.RemoveAll(targetPath); err != nil { + return err + } + } + return nil +} + +// Evaluate evaluates every target state entry in s. +func (ss *SourceState) Evaluate() error { + for _, targetName := range ss.sortedTargetNames() { + sourceStateEntry := ss.Entries[targetName] + if err := sourceStateEntry.Evaluate(); err != nil { + return err + } + targetStateEntry, err := sourceStateEntry.TargetStateEntry() + if err != nil { + return err + } + if err := targetStateEntry.Evaluate(); err != nil { + return err + } + } + return nil +} + +func (ss *SourceState) addPatterns(ps *PatternSet, path, relPath string) error { + data, err := ss.executeTemplate(path) + if err != nil { + return err + } + dir := filepath.Dir(relPath) + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + text := scanner.Text() + if index := strings.IndexRune(text, '#'); index != -1 { + text = text[:index] + } + text = strings.TrimSpace(text) + if text == "" { + continue + } + include := true + if strings.HasPrefix(text, "!") { + include = false + text = strings.TrimPrefix(text, "!") + } + pattern := filepath.Join(dir, text) + if err := ps.Add(pattern, include); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + return nil +} + +func (ss *SourceState) addTemplatesDir(templateDir string) error { + templateDirPrefix := filepath.ToSlash(templateDir) + pathSeparatorStr + return vfs.Walk(ss.s, templateDir, func(templatePath string, info os.FileInfo, err error) error { + templatePath = filepath.ToSlash(templatePath) + if err != nil { + return err + } + switch { + case info.Mode().IsRegular(): + contents, err := ss.s.ReadFile(templatePath) + if err != nil { + return err + } + name := strings.TrimPrefix(templatePath, templateDirPrefix) + tmpl, err := template.New(name).Parse(string(contents)) + if err != nil { + return err + } + if ss.templates == nil { + ss.templates = make(map[string]*template.Template) + } + ss.templates[name] = tmpl + return nil + case info.IsDir(): + return nil + default: + return &unsupportedFileTypeError{ + path: templatePath, + mode: info.Mode(), + } + } + }) +} + +// addVersionFile reads a .chezmoiversion file from source path and updates ss's +// minimum version if it contains a more recent version than the current minimum +// version. +func (ss *SourceState) addVersionFile(sourcePath string) error { + data, err := ss.s.ReadFile(sourcePath) + if err != nil { + return err + } + version, err := semver.NewVersion(strings.TrimSpace(string(data))) + if err != nil { + return err + } + if ss.minVersion == nil || ss.minVersion.LessThan(*version) { + ss.minVersion = version + } + return nil +} + +func (ss *SourceState) executeTemplate(path string) ([]byte, error) { + data, err := ss.s.ReadFile(path) + if err != nil { + return nil, err + } + return ss.ExecuteTemplateData(path, data) +} + +func (ss *SourceState) newSourceStateDir(sourcePath string, dirAttributes DirAttributes) *SourceStateDir { + perm := os.FileMode(0o777) + if dirAttributes.Private { + perm &^= 0o77 + } + perm &^= ss.umask + + targetStateDir := &TargetStateDir{ + perm: perm, + exact: dirAttributes.Exact, + } + + return &SourceStateDir{ + path: sourcePath, + attributes: dirAttributes, + targetStateEntry: targetStateDir, + } +} + +func (ss *SourceState) newSourceStateFile(sourcePath string, fileAttributes FileAttributes) *SourceStateFile { + lazyContents := &lazyContents{ + contentsFunc: func() ([]byte, error) { + contents, err := ss.s.ReadFile(sourcePath) + if err != nil { + return nil, err + } + if !fileAttributes.Encrypted { + return contents, nil + } + // FIXME pass targetName as filenameHint + return ss.encryptionTool.Decrypt(sourcePath, contents) + }, + } + + var targetStateEntryFunc func() (TargetStateEntry, error) + switch fileAttributes.Type { + case SourceFileTypeFile: + targetStateEntryFunc = func() (TargetStateEntry, error) { + contents, err := lazyContents.Contents() + if err != nil { + return nil, err + } + if fileAttributes.Template { + contents, err = ss.ExecuteTemplateData(sourcePath, contents) + if err != nil { + return nil, err + } + } + if !fileAttributes.Empty && isEmpty(contents) { + return &TargetStateAbsent{}, nil + } + perm := os.FileMode(0o666) + if fileAttributes.Executable { + perm |= 0o111 + } + if fileAttributes.Private { + perm &^= 0o77 + } + perm &^= ss.umask + return &TargetStateFile{ + lazyContents: newLazyContents(contents), + perm: perm, + }, nil + } + case SourceFileTypeScript: + targetStateEntryFunc = func() (TargetStateEntry, error) { + contents, err := lazyContents.Contents() + if err != nil { + return nil, err + } + if fileAttributes.Template { + contents, err = ss.ExecuteTemplateData(sourcePath, contents) + if err != nil { + return nil, err + } + } + return &TargetStateScript{ + lazyContents: newLazyContents(contents), + name: fileAttributes.Name, + once: fileAttributes.Once, + }, nil + } + case SourceFileTypeSymlink: + targetStateEntryFunc = func() (TargetStateEntry, error) { + linknameBytes, err := lazyContents.Contents() + if err != nil { + return nil, err + } + if fileAttributes.Template { + linknameBytes, err = ss.ExecuteTemplateData(sourcePath, linknameBytes) + if err != nil { + return nil, err + } + } + return &TargetStateSymlink{ + lazyLinkname: newLazyLinkname(string(linknameBytes)), + }, nil + } + default: + panic(fmt.Sprintf("unsupported type: %s", string(fileAttributes.Type))) + } + + return &SourceStateFile{ + lazyContents: lazyContents, + path: sourcePath, + attributes: fileAttributes, + targetStateEntryFunc: targetStateEntryFunc, + } +} + +// sortedTargetNames returns all of ss's target names in order. +func (ss *SourceState) sortedTargetNames() []string { + targetNames := make([]string, 0, len(ss.Entries)) + for targetName := range ss.Entries { + targetNames = append(targetNames, targetName) + } + sort.Strings(targetNames) + return targetNames +} + +// getTargetDirName returns the target directory name of sourceDirName. +func getTargetDirName(sourceDirName string) string { + sourceNames := strings.Split(sourceDirName, pathSeparatorStr) + targetNames := make([]string, 0, len(sourceNames)) + for _, sourceName := range sourceNames { + dirAttributes := ParseDirAttributes(sourceName) + targetNames = append(targetNames, dirAttributes.Name) + } + return strings.Join(targetNames, pathSeparatorStr) +} diff --git a/v2/internal/chezmoi/sourcestate_test.go b/v2/internal/chezmoi/sourcestate_test.go new file mode 100644 index 000000000000..a0e5a5311b03 --- /dev/null +++ b/v2/internal/chezmoi/sourcestate_test.go @@ -0,0 +1,707 @@ +package chezmoi + +import ( + "os" + "testing" + "text/template" + + "github.com/coreos/go-semver/semver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs/vfst" +) + +func TestSourceStateApplyAll(t *testing.T) { + // FIXME script tests + // FIXME script template tests + for _, tc := range []struct { + name string + root interface{} + sourceStateOptions []SourceStateOption + tests []interface{} + }{ + { + name: "empty", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": &vfst.Dir{Perm: 0o755}, + }, + }, + }, + { + name: "dir", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "foo": &vfst.Dir{Perm: 0o755}, + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestIsDir, + vfst.TestModePerm(0o755), + ), + }, + }, + { + name: "dir_exact", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "", + }, + ".local/share/chezmoi": map[string]interface{}{ + "exact_foo": &vfst.Dir{Perm: 0o755}, + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestIsDir, + vfst.TestModePerm(0o755), + ), + vfst.TestPath("/home/user/foo/bar", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "file", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o644), + vfst.TestContentsString("bar"), + ), + }, + }, + { + name: "file_remove_empty", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "foo": "", + ".local/share/chezmoi": map[string]interface{}{ + "foo": "", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "file_create_empty", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "empty_foo": "", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o644), + vfst.TestContentsString(""), + ), + }, + }, + { + name: "file_template", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "foo.tmpl": "email = {{ .email }}", + }, + }, + }, + sourceStateOptions: []SourceStateOption{ + WithTemplateData(map[string]interface{}{ + "email": "john.smith@company.com", + }), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o644), + vfst.TestContentsString("email = john.smith@company.com"), + ), + }, + }, + { + name: "symlink", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "symlink_foo": "bar", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestModeType(os.ModeSymlink), + vfst.TestSymlinkTarget("bar"), + ), + }, + }, + { + name: "symlink_template", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "symlink_foo.tmpl": "bar_{{ .os }}", + }, + }, + }, + sourceStateOptions: []SourceStateOption{ + WithTemplateData(map[string]interface{}{ + "os": "linux", + }), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestModeType(os.ModeSymlink), + vfst.TestSymlinkTarget("bar_linux"), + ), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(tc.root) + require.NoError(t, err) + defer cleanup() + + s := newTestRealSystem(fs) + sourceStateOptions := []SourceStateOption{ + WithSystem(s), + WithSourcePath("/home/user/.local/share/chezmoi"), + } + sourceStateOptions = append(sourceStateOptions, tc.sourceStateOptions...) + ss := NewSourceState(sourceStateOptions...) + require.NoError(t, ss.Read()) + require.NoError(t, ss.Evaluate()) + require.NoError(t, ss.ApplyAll(s, vfst.DefaultUmask, "/home/user")) + + vfst.RunTests(t, fs, "", tc.tests...) + }) + } +} + +func TestSourceStateRead(t *testing.T) { + for _, tc := range []struct { + name string + root interface{} + sourceStateOptions []SourceStateOption + expectedError string + expectedSourceState *SourceState + }{ + { + name: "empty", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": &vfst.Dir{Perm: 0o755}, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + ), + }, + { + name: "dir", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": &vfst.Dir{Perm: 0o755}, + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateDir{ + path: "/home/user/.local/share/chezmoi/foo", + attributes: DirAttributes{ + Name: "foo", + }, + targetStateEntry: &TargetStateDir{ + perm: 0o755, + }, + }, + }), + ), + }, + { + name: "file", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": "bar", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateFile{ + path: "/home/user/.local/share/chezmoi/foo", + attributes: FileAttributes{ + Name: "foo", + Type: SourceFileTypeFile, + }, + lazyContents: newLazyContents([]byte("bar")), + targetStateEntry: &TargetStateFile{ + perm: 0o644, + lazyContents: newLazyContents([]byte("bar")), + }, + }, + }), + ), + }, + { + name: "duplicate_target", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": "bar", + "foo.tmpl": "bar", + }, + }, + expectedError: "foo: duplicate target (/home/user/.local/share/chezmoi/foo, /home/user/.local/share/chezmoi/foo.tmpl)", + }, + { + name: "duplicate_target", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": "bar", + "exact_foo": &vfst.Dir{Perm: 0o755}, + }, + }, + expectedError: "foo: duplicate target (/home/user/.local/share/chezmoi/exact_foo, /home/user/.local/share/chezmoi/foo)", + }, + { + name: "symlink", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": &vfst.Symlink{Target: "bar"}, + }, + }, + expectedError: "/home/user/.local/share/chezmoi/foo: unsupported file type symlink", + }, + { + name: "script", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "run_foo": "bar", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateFile{ + path: "/home/user/.local/share/chezmoi/run_foo", + attributes: FileAttributes{ + Name: "foo", + Type: SourceFileTypeScript, + }, + lazyContents: newLazyContents([]byte("bar")), + targetStateEntry: &TargetStateScript{ + name: "foo", + lazyContents: newLazyContents([]byte("bar")), + }, + }, + }), + ), + }, + { + name: "symlink", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "symlink_foo": "bar", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateFile{ + path: "/home/user/.local/share/chezmoi/symlink_foo", + attributes: FileAttributes{ + Name: "foo", + Type: SourceFileTypeSymlink, + }, + lazyContents: newLazyContents([]byte("bar")), + targetStateEntry: &TargetStateSymlink{ + lazyLinkname: newLazyLinkname("bar"), + }, + }, + }), + ), + }, + { + name: "file_in_dir", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "baz", + }, + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateDir{ + path: "/home/user/.local/share/chezmoi/foo", + attributes: DirAttributes{ + Name: "foo", + }, + targetStateEntry: &TargetStateDir{ + perm: 0o755, + }, + }, + "foo/bar": &SourceStateFile{ + path: "/home/user/.local/share/chezmoi/foo/bar", + attributes: FileAttributes{ + Name: "bar", + Type: SourceFileTypeFile, + }, + lazyContents: &lazyContents{ + contents: []byte("baz"), + }, + targetStateEntry: &TargetStateFile{ + perm: 0o644, + lazyContents: &lazyContents{ + contents: []byte("baz"), + }, + }, + }, + }), + ), + }, + { + name: "chezmoiignore", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withIgnore( + NewPatternSet( + withAdd(t, "README.md", true), + ), + ), + ), + }, + { + name: "chezmoiignore_ignore_file", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + "README.md": "", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withIgnore( + NewPatternSet( + withAdd(t, "README.md", true), + ), + ), + ), + }, + { + name: "chezmoiremove", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiremove": "!*.txt\n", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withRemove( + NewPatternSet( + withAdd(t, "*.txt", false), + ), + ), + ), + }, + { + name: "chezmoitemplates", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoitemplates": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withTemplates( + map[string]*template.Template{ + "foo": template.Must(template.New("foo").Parse("bar")), + }, + ), + ), + }, + { + name: "chezmoiversion", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiversion": "1.2.3\n", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withMinVersion( + &semver.Version{ + Major: 1, + Minor: 2, + Patch: 3, + }, + ), + ), + }, + { + name: "chezmoiversion_multiple", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiversion": "1.2.3\n", + "foo": map[string]interface{}{ + ".chezmoiversion": "2.3.4\n", + }, + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateDir{ + path: "/home/user/.local/share/chezmoi/foo", + attributes: DirAttributes{ + Name: "foo", + }, + targetStateEntry: &TargetStateDir{ + perm: 0o755, + }, + }, + }), + withMinVersion( + &semver.Version{ + Major: 2, + Minor: 3, + Patch: 4, + }, + ), + ), + }, + { + name: "ignore_dir", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".ignore": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + ), + }, + { + name: "ignore_file", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".ignore": "", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + ), + }, + } { + t.Run(tc.name, func(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(tc.root) + require.NoError(t, err) + defer cleanup() + + sourceStateOptions := []SourceStateOption{ + WithSystem(newTestRealSystem(fs)), + WithSourcePath("/home/user/.local/share/chezmoi"), + } + sourceStateOptions = append(sourceStateOptions, tc.sourceStateOptions...) + ss := NewSourceState(sourceStateOptions...) + err = ss.Read() + if tc.expectedError != "" { + assert.Error(t, err) + assert.Equal(t, tc.expectedError, err.Error()) + return + } + require.NoError(t, err) + require.NoError(t, ss.Evaluate()) + require.NoError(t, tc.expectedSourceState.Evaluate()) + ss.s = nil + assert.Equal(t, tc.expectedSourceState, ss) + }) + } +} + +func TestSourceStateRemove(t *testing.T) { + // FIXME test doublestar globs + for _, tc := range []struct { + name string + root interface{} + tests []vfst.Test + }{ + { + name: "empty", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": &vfst.Dir{Perm: 0o755}, + }, + }, + { + name: "dir", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "dir": &vfst.Dir{Perm: 0o755}, + "file": "", + "symlink": &vfst.Symlink{Target: "file"}, + ".local/share/chezmoi": map[string]interface{}{ + ".chezmoiremove": "dir\n", + }, + }, + }, + tests: []vfst.Test{ + vfst.TestPath("/home/user/dir", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/file", + vfst.TestModeIsRegular, + ), + vfst.TestPath("/home/user/symlink", + vfst.TestModeType(os.ModeSymlink), + ), + }, + }, + { + name: "file", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "dir": &vfst.Dir{Perm: 0o755}, + "file": "", + "symlink": &vfst.Symlink{Target: "file"}, + ".local/share/chezmoi": map[string]interface{}{ + ".chezmoiremove": "file\n", + }, + }, + }, + tests: []vfst.Test{ + vfst.TestPath("/home/user/dir", + vfst.TestIsDir, + ), + vfst.TestPath("/home/user/file", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/symlink", + vfst.TestModeType(os.ModeSymlink), + ), + }, + }, + { + name: "symlink", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "dir": &vfst.Dir{Perm: 0o755}, + "file": "", + "symlink": &vfst.Symlink{Target: "file"}, + ".local/share/chezmoi": map[string]interface{}{ + ".chezmoiremove": "symlink\n", + }, + }, + }, + tests: []vfst.Test{ + vfst.TestPath("/home/user/dir", + vfst.TestIsDir, + ), + vfst.TestPath("/home/user/file", + vfst.TestModeIsRegular, + ), + vfst.TestPath("/home/user/symlink", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "exclude_pattern", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "foo": "", + "bar": "", + "baz": "", + ".local/share/chezmoi": map[string]interface{}{ + ".chezmoiremove": "b*\n!*z\n", + }, + }, + }, + tests: []vfst.Test{ + vfst.TestPath("/home/user/foo", + vfst.TestModeIsRegular, + ), + vfst.TestPath("/home/user/bar", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/baz", + vfst.TestModeIsRegular, + ), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(tc.root) + require.NoError(t, err) + defer cleanup() + + ss := NewSourceState( + WithSystem(newTestRealSystem(fs)), + WithSourcePath("/home/user/.local/share/chezmoi"), + ) + require.NoError(t, ss.Read()) + require.NoError(t, ss.Evaluate()) + + require.NoError(t, ss.Remove(newTestRealSystem(fs), "/home/user")) + + vfst.RunTests(t, fs, "", tc.tests) + }) + } +} + +func withEntries(sourceEntries map[string]SourceStateEntry) SourceStateOption { + return func(ss *SourceState) { + ss.Entries = sourceEntries + } +} + +func withIgnore(ignore *PatternSet) SourceStateOption { + return func(ss *SourceState) { + ss.ignore = ignore + } +} + +func withMinVersion(minVersion *semver.Version) SourceStateOption { + return func(ss *SourceState) { + ss.minVersion = minVersion + } +} + +func withRemove(remove *PatternSet) SourceStateOption { + return func(ss *SourceState) { + ss.remove = remove + } +} + +func withTemplates(templates map[string]*template.Template) SourceStateOption { + return func(ss *SourceState) { + ss.templates = templates + } +} diff --git a/v2/internal/chezmoi/sourcestateentry.go b/v2/internal/chezmoi/sourcestateentry.go new file mode 100644 index 000000000000..89f81386b978 --- /dev/null +++ b/v2/internal/chezmoi/sourcestateentry.go @@ -0,0 +1,79 @@ +package chezmoi + +import ( + "os" +) + +// A SourceStateEntry represents the state of an entry in the source state. +type SourceStateEntry interface { + Evaluate() error + Path() string + TargetStateEntry() (TargetStateEntry, error) + Write(s System, umask os.FileMode) error +} + +// A SourceStateDir represents the state of a directory in the source state. +type SourceStateDir struct { + path string + attributes DirAttributes + targetStateEntry TargetStateEntry +} + +// A SourceStateFile represents the state of a file in the source state. +type SourceStateFile struct { + *lazyContents + path string + attributes FileAttributes + targetStateEntryFunc func() (TargetStateEntry, error) + targetStateEntry TargetStateEntry + targetStateEntryErr error +} + +// Evaluate evaluates s and returns any error. +func (s *SourceStateDir) Evaluate() error { + return nil +} + +// Path returns s's path. +func (s *SourceStateDir) Path() string { + return s.path +} + +// TargetStateEntry returns s's target state entry. +func (s *SourceStateDir) TargetStateEntry() (TargetStateEntry, error) { + return s.targetStateEntry, nil +} + +// Write writes s to sourceStateDir. +func (s *SourceStateDir) Write(sourceStateDir System, umask os.FileMode) error { + return sourceStateDir.Mkdir(s.path, 0o777&^umask) +} + +// Evaluate evaluates s and returns any error. +func (s *SourceStateFile) Evaluate() error { + _, err := s.ContentsSHA256() + return err +} + +// Path returns s's path. +func (s *SourceStateFile) Path() string { + return s.path +} + +// TargetStateEntry returns s's target state entry. +func (s *SourceStateFile) TargetStateEntry() (TargetStateEntry, error) { + if s.targetStateEntryFunc != nil { + s.targetStateEntry, s.targetStateEntryErr = s.targetStateEntryFunc() + s.targetStateEntryFunc = nil + } + return s.targetStateEntry, s.targetStateEntryErr +} + +// Write writes s to sourceStateDir. +func (s *SourceStateFile) Write(sourceStateDir System, umask os.FileMode) error { + contents, err := s.Contents() + if err != nil { + return err + } + return sourceStateDir.WriteFile(s.path, contents, 0o666&^umask) +} diff --git a/v2/internal/chezmoi/stringset.go b/v2/internal/chezmoi/stringset.go new file mode 100644 index 000000000000..491b433f5bac --- /dev/null +++ b/v2/internal/chezmoi/stringset.go @@ -0,0 +1,33 @@ +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 element is in s. +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 +} diff --git a/v2/internal/chezmoi/system.go b/v2/internal/chezmoi/system.go new file mode 100644 index 000000000000..b20e06521492 --- /dev/null +++ b/v2/internal/chezmoi/system.go @@ -0,0 +1,32 @@ +package chezmoi + +import ( + "os" + "os/exec" +) + +// A SystemReader reads from a filesystem and executes idempotent commands. +type SystemReader interface { + PersistentStateReader + Glob(pattern string) ([]string, error) + IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) + Lstat(filename string) (os.FileInfo, error) + ReadDir(dirname string) ([]os.FileInfo, error) + ReadFile(filename string) ([]byte, error) + Readlink(name string) (string, error) + Stat(name string) (os.FileInfo, error) +} + +// A System reads from and writes to a filesystem, executes idempotent commands, +// runs scripts, and persists state. +type System interface { + PersistentStateWriter + SystemReader + Chmod(name string, mode os.FileMode) error + Mkdir(name string, perm os.FileMode) error + RemoveAll(name string) error + Rename(oldpath, newpath string) error + RunScript(scriptname string, data []byte) error + WriteFile(filename string, data []byte, perm os.FileMode) error + WriteSymlink(oldname, newname string) error +} diff --git a/v2/internal/chezmoi/targetstateentry.go b/v2/internal/chezmoi/targetstateentry.go new file mode 100644 index 000000000000..ecdaf9f3e4a6 --- /dev/null +++ b/v2/internal/chezmoi/targetstateentry.go @@ -0,0 +1,287 @@ +package chezmoi + +// FIXME remove logging in Equal +// FIXME I don't think we need to use lazyContents here, except the SHA256 stuff is useful + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "log" + "os" + "time" +) + +// A TargetStateEntry represents the state of an entry in the target state. +type TargetStateEntry interface { + Apply(s System, destStateEntry DestStateEntry) error + Equal(destStateEntry DestStateEntry) (bool, error) + Evaluate() error +} + +// A TargetStateAbsent represents the absence of an entry in the target state. +type TargetStateAbsent struct{} + +// A TargetStateDir represents the state of a directory in the target state. +type TargetStateDir struct { + perm os.FileMode + exact bool +} + +// A TargetStateFile represents the state of a file in the target state. +type TargetStateFile struct { + *lazyContents + perm os.FileMode +} + +// A TargetStateScript represents the state of a script. +type TargetStateScript struct { + *lazyContents + name string + once bool +} + +// A TargetStateSymlink represents the state of a symlink in the target state. +type TargetStateSymlink struct { + *lazyLinkname +} + +// A scriptOnceState records the state of a script that should only be run once. +type scriptOnceState struct { + Name string `json:"name"` + ExecutedAt time.Time `json:"executedAt"` // FIXME should be runAt? +} + +// Apply updates destStateEntry to match t. +func (t *TargetStateAbsent) Apply(s System, destStateEntry DestStateEntry) error { + if _, ok := destStateEntry.(*DestStateAbsent); ok { + return nil + } + return s.RemoveAll(destStateEntry.Path()) +} + +// Equal returns true if destStateEntry matches t. +func (t *TargetStateAbsent) Equal(destStateEntry DestStateEntry) (bool, error) { + _, ok := destStateEntry.(*DestStateAbsent) + if !ok { + log.Printf("other is a %T, want *DestStateAbsent\n", destStateEntry) + } + return ok, nil +} + +// Evaluate evaluates t. +func (t *TargetStateAbsent) Evaluate() error { + return nil +} + +// Apply updates destStateEntry to match t. It does not recurse. +func (t *TargetStateDir) Apply(s System, destStateEntry DestStateEntry) error { + if destStateDir, ok := destStateEntry.(*DestStateDir); ok { + if destStateDir.perm == t.perm { + return nil + } + return s.Chmod(destStateDir.Path(), t.perm) + } + if err := destStateEntry.Remove(s); err != nil { + return err + } + return s.Mkdir(destStateEntry.Path(), t.perm) +} + +// Equal returns true if destStateEntry matches t. It does not recurse. +func (t *TargetStateDir) Equal(destStateEntry DestStateEntry) (bool, error) { + destStateDir, ok := destStateEntry.(*DestStateDir) + if !ok { + log.Printf("other is a %T, want *DestStateDir\n", destStateEntry) + return false, nil + } + if destStateDir.perm != t.perm { + log.Printf("other has perm %o, want %o", destStateDir.perm, t.perm) + } + return true, nil +} + +// Evaluate evaluates t. +func (t *TargetStateDir) Evaluate() error { + return nil +} + +// Apply updates destStateEntry to match t. +func (t *TargetStateFile) Apply(s System, destStateEntry DestStateEntry) error { + if destStateFile, ok := destStateEntry.(*DestStateFile); ok { + // Compare file contents using only their SHA256 sums. This is so that + // we can compare last-written states without storing the full contents + // of each file written. + destContentsSHA256, err := destStateFile.ContentsSHA256() + if err != nil { + return err + } + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return err + } + if bytes.Equal(destContentsSHA256, contentsSHA256) { + if destStateFile.perm == t.perm { + return nil + } + return s.Chmod(destStateFile.Path(), t.perm) + } + } else if err := destStateEntry.Remove(s); err != nil { + return err + } + contents, err := t.Contents() + if err != nil { + return err + } + return s.WriteFile(destStateEntry.Path(), contents, t.perm) +} + +// Equal returns true if destStateEntry matches t. +func (t *TargetStateFile) Equal(destStateEntry DestStateEntry) (bool, error) { + destStateFile, ok := destStateEntry.(*DestStateFile) + if !ok { + log.Printf("other is a %T, not a *DestStateFile\n", destStateEntry) + return false, nil + } + if posixFileModes && destStateFile.perm != t.perm { + log.Printf("other has perm %o, want %o", destStateFile.perm, t.perm) + return false, nil + } + destContentsSHA256, err := destStateFile.ContentsSHA256() + if err != nil { + return false, err + } + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return false, err + } + if !bytes.Equal(destContentsSHA256, contentsSHA256) { + log.Printf("contents SHA256 don't match") + } + return true, nil +} + +// Evaluate evaluates t. +func (t *TargetStateFile) Evaluate() error { + _, err := t.ContentsSHA256() + return err +} + +// Apply runs t. +func (t *TargetStateScript) Apply(s System, destStateEntry DestStateEntry) error { + var ( + bucket = scriptOnceStateBucket + key []byte + executedAt time.Time + ) + if t.once { + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return err + } + // FIXME the following assumes that the script name is part of the script state + // FIXME maybe it shouldn't be + key = []byte(t.name + ":" + hex.EncodeToString(contentsSHA256)) + scriptOnceState, err := s.Get(bucket, key) + if err != nil { + return err + } + if scriptOnceState != nil { + return nil + } + executedAt = time.Now() + } + contents, err := t.Contents() + if err != nil { + return err + } + if isEmpty(contents) { + return nil + } + if err := s.RunScript(t.name, contents); err != nil { + return err + } + if t.once { + value, err := json.Marshal(&scriptOnceState{ + Name: t.name, + ExecutedAt: executedAt, + }) + if err != nil { + return err + } + if err := s.Set(bucket, key, value); err != nil { + return err + } + } + return nil +} + +// Equal returns true if destStateEntry matches t. +func (t *TargetStateScript) Equal(destStateEntry DestStateEntry) (bool, error) { + // Scripts are independent of the destination state. + // FIXME maybe the destination state should store the sha256 sums of executed scripts + return true, nil +} + +// Evaluate evaluates t. +func (t *TargetStateScript) Evaluate() error { + _, err := t.ContentsSHA256() + return err +} + +// Apply updates destStateEntry to match t. +func (t *TargetStateSymlink) Apply(s System, destStateEntry DestStateEntry) error { + if destStateSymlink, ok := destStateEntry.(*DestStateSymlink); ok { + destLinkname, err := destStateSymlink.Linkname() + if err != nil { + return err + } + linkname, err := t.Linkname() + if err != nil { + return err + } + if destLinkname == linkname { + return nil + } + } + linkname, err := t.Linkname() + if err != nil { + return err + } + if err := destStateEntry.Remove(s); err != nil { + return err + } + return s.WriteSymlink(linkname, destStateEntry.Path()) +} + +// Equal returns true if destStateEntry matches t. +func (t *TargetStateSymlink) Equal(destStateEntry DestStateEntry) (bool, error) { + destStateSymlink, ok := destStateEntry.(*DestStateSymlink) + if !ok { + log.Printf("other is a %T, want *DestStateSymlink\n", destStateEntry) + return false, nil + } + destLinkname, err := destStateSymlink.Linkname() + if err != nil { + return false, err + } + linkname, err := t.Linkname() + if err != nil { + return false, nil + } + if destLinkname != linkname { + log.Printf("other has linkname %s, want %s", destLinkname, linkname) + return false, nil + } + return true, nil +} + +// Evaluate evaluates t. +func (t *TargetStateSymlink) Evaluate() error { + _, err := t.Linkname() + return err +} + +func isEmpty(data []byte) bool { + return len(bytes.TrimSpace(data)) == 0 +} diff --git a/v2/internal/chezmoi/targetstatentry_test.go b/v2/internal/chezmoi/targetstatentry_test.go new file mode 100644 index 000000000000..d7920a9dc0e7 --- /dev/null +++ b/v2/internal/chezmoi/targetstatentry_test.go @@ -0,0 +1,174 @@ +package chezmoi + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs/vfst" +) + +func TestTargetStateEntryApplyAndEqual(t *testing.T) { + for _, tc1 := range []struct { + name string + targetStateEntry TargetStateEntry + }{ + { + name: "absent", + targetStateEntry: &TargetStateAbsent{}, + }, + { + name: "dir", + targetStateEntry: &TargetStateDir{ + perm: 0o755, + }, + }, + { + name: "file", + targetStateEntry: &TargetStateFile{ + perm: 0o644, + lazyContents: &lazyContents{ + contents: []byte("bar"), + }, + }, + }, + { + name: "file_empty", + targetStateEntry: &TargetStateFile{ + perm: 0o644, + }, + }, + { + name: "file_empty_ok", + targetStateEntry: &TargetStateFile{ + perm: 0o644, + }, + }, + { + name: "symlink", + targetStateEntry: &TargetStateSymlink{ + lazyLinkname: &lazyLinkname{ + linkname: "bar", + }, + }, + }, + } { + t.Run(tc1.name, func(t *testing.T) { + for _, tc2 := range []struct { + name string + root interface{} + }{ + { + name: "not_present", + root: map[string]interface{}{ + "/home/user": &vfst.Dir{Perm: 0o755}, + }, + }, + { + name: "existing_dir", + root: map[string]interface{}{ + "/home/user/foo": &vfst.Dir{Perm: 0o755}, + }, + }, + { + name: "existing_dir_chmod", + root: map[string]interface{}{ + "/home/user/foo": &vfst.Dir{Perm: 0o644}, + }, + }, + { + name: "existing_file_empty", + root: map[string]interface{}{ + "/home/user/foo": "", + }, + }, + { + name: "existing_file_contents", + root: map[string]interface{}{ + "/home/user/foo": "baz", + }, + }, + { + name: "existing_file_chmod", + root: map[string]interface{}{ + "/home/user/foo": &vfst.File{ + Perm: 0o755, + }, + }, + }, + { + name: "existing_symlink", + root: map[string]interface{}{ + "/home/user/bar": "", + "/home/user/foo": &vfst.Symlink{Target: "bar"}, + }, + }, + { + name: "existing_symlink_broken", + root: map[string]interface{}{ + "/home/user/foo": &vfst.Symlink{Target: "bar"}, + }, + }, + } { + t.Run(tc2.name, func(t *testing.T) { + testFS, cleanup, err := vfst.NewTestFS(tc2.root) + require.NoError(t, err) + defer cleanup() + fs := NewRealSystem(testFS, newTestPersistentState()) + + // Read the initial destination state entry from fs. + destStateEntry, err := NewDestStateEntry(fs, "/home/user/foo") + require.NoError(t, err) + + // Apply the target state entry. + require.NoError(t, tc1.targetStateEntry.Apply(NewRealSystem(fs, newTestPersistentState()), destStateEntry)) + + // Verify that the destination state entry matches the + // desired state. + vfst.RunTests(t, fs, "", vfst.TestPath("/home/user/foo", targetStateTest(t, tc1.targetStateEntry)...)) + + // Read the updated destination state entry from fs and + // verify that it is equal to the target state entry. + newDestStateEntry, err := NewDestStateEntry(fs, "/home/user/foo") + require.NoError(t, err) + equal, err := tc1.targetStateEntry.Equal(newDestStateEntry) + require.NoError(t, err) + require.True(t, equal) + }) + } + }) + } +} + +func targetStateTest(t *testing.T, ts TargetStateEntry) []vfst.PathTest { + switch ts := ts.(type) { + case *TargetStateAbsent: + return []vfst.PathTest{ + vfst.TestDoesNotExist, + } + case *TargetStateDir: + return []vfst.PathTest{ + vfst.TestIsDir, + vfst.TestModePerm(ts.perm), + } + case *TargetStateFile: + expectedContents, err := ts.Contents() + require.NoError(t, err) + return []vfst.PathTest{ + vfst.TestModeIsRegular, + vfst.TestModePerm(ts.perm), + vfst.TestContents(expectedContents), + } + case *TargetStateScript: + return nil // FIXME how to verify scripts? + case *TargetStateSymlink: + expectedLinkname, err := ts.Linkname() + require.NoError(t, err) + return []vfst.PathTest{ + vfst.TestModeType(os.ModeSymlink), + vfst.TestSymlinkTarget(expectedLinkname), + } + default: + return nil + } +} diff --git a/v2/internal/chezmoi/tarsystem.go b/v2/internal/chezmoi/tarsystem.go new file mode 100644 index 000000000000..5e500cd8c824 --- /dev/null +++ b/v2/internal/chezmoi/tarsystem.go @@ -0,0 +1,114 @@ +package chezmoi + +import ( + "archive/tar" + "io" + "os" + "os/user" + "strconv" + "time" +) + +// A TARSystem is a System that writes to a TAR archive. +type TARSystem struct { + nullSystem + w *tar.Writer + headerTemplate tar.Header +} + +// NewTARSystem returns a new TARSystem that writes a TAR file to w. +func NewTARSystem(w io.Writer, headerTemplate tar.Header) *TARSystem { + return &TARSystem{ + w: tar.NewWriter(w), + headerTemplate: headerTemplate, + } +} + +// Chmod implements System.Chmod. +func (s *TARSystem) Chmod(name string, mode os.FileMode) error { + return os.ErrPermission +} + +// Close closes m. +func (s *TARSystem) Close() error { + return s.w.Close() +} + +// Mkdir implements System.Mkdir. +func (s *TARSystem) Mkdir(name string, perm os.FileMode) error { + header := s.headerTemplate + header.Typeflag = tar.TypeDir + header.Name = name + "/" + header.Mode = int64(perm) + return s.w.WriteHeader(&header) +} + +// RemoveAll implements System.RemoveAll. +func (s *TARSystem) RemoveAll(name string) error { + return os.ErrPermission +} + +// Rename implements System.Rename. +func (s *TARSystem) Rename(oldpath, newpath string) error { + return os.ErrPermission +} + +// RunScript implements System.RunScript. +func (s *TARSystem) RunScript(scriptname string, data []byte) error { + return s.WriteFile(scriptname, data, 0o700) +} + +// WriteFile implements System.WriteFile. +func (s *TARSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { + header := s.headerTemplate + header.Typeflag = tar.TypeReg + header.Name = filename + header.Size = int64(len(data)) + header.Mode = int64(perm) + if err := s.w.WriteHeader(&header); err != nil { + return err + } + _, err := s.w.Write(data) + return err +} + +// WriteSymlink implements System.WriteSymlink. +func (s *TARSystem) WriteSymlink(oldname, newname string) error { + header := s.headerTemplate + header.Typeflag = tar.TypeSymlink + header.Name = newname + header.Linkname = oldname + return s.w.WriteHeader(&header) +} + +// TARHeaderTemplate returns a tar.Header template populated with the current +// user and time. +func TARHeaderTemplate() tar.Header { + // Attempt to lookup the current user. Ignore errors because the default + // zero values are reasonable. + var ( + uid int + gid int + Uname string + Gname string + ) + if currentUser, err := user.Current(); err == nil { + uid, _ = strconv.Atoi(currentUser.Uid) + gid, _ = strconv.Atoi(currentUser.Gid) + Uname = currentUser.Username + if group, err := user.LookupGroupId(currentUser.Gid); err == nil { + Gname = group.Name + } + } + + now := time.Now() + return tar.Header{ + Uid: uid, + Gid: gid, + Uname: Uname, + Gname: Gname, + ModTime: now, + AccessTime: now, + ChangeTime: now, + } +} diff --git a/v2/internal/chezmoi/tarsystem_test.go b/v2/internal/chezmoi/tarsystem_test.go new file mode 100644 index 000000000000..4ec6e90d2598 --- /dev/null +++ b/v2/internal/chezmoi/tarsystem_test.go @@ -0,0 +1,96 @@ +package chezmoi + +import ( + "archive/tar" + "bytes" + "io" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs/vfst" +) + +var _ System = &TARSystem{} + +func TestTARSystem(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + ".chezmoiremove": "*.txt\n", + ".chezmoiversion": "1.2.3\n", + ".chezmoitemplates": map[string]interface{}{ + "foo": "bar", + }, + "README.md": "", + "dir": map[string]interface{}{ + "foo": "bar", + }, + "run_script": "#!/bin/sh\n", + "symlink_symlink": "bar", + }, + }) + require.NoError(t, err) + defer cleanup() + + s := NewSourceState( + WithSystem(NewRealSystem(fs, newTestPersistentState())), + WithSourcePath("/home/user/.local/share/chezmoi"), + ) + require.NoError(t, s.Read()) + require.NoError(t, s.Evaluate()) + + b := &bytes.Buffer{} + tarSystem := NewTARSystem(b, tar.Header{}) + require.NoError(t, s.ApplyAll(tarSystem, vfst.DefaultUmask, "")) + + r := tar.NewReader(b) + for _, tc := range []struct { + expectedTypeflag byte + expectedName string + expectedMode int64 + expectedLinkname string + expectedContents []byte + }{ + { + expectedTypeflag: tar.TypeDir, + expectedName: "dir/", + expectedMode: 0o755, + }, + { + expectedTypeflag: tar.TypeReg, + expectedName: "dir/foo", + expectedContents: []byte("bar"), + expectedMode: 0o644, + }, + { + expectedTypeflag: tar.TypeReg, + expectedName: "script", + expectedContents: []byte("#!/bin/sh\n"), + expectedMode: 0o700, + }, + { + expectedTypeflag: tar.TypeSymlink, + expectedName: "symlink", + expectedLinkname: "bar", + }, + } { + t.Run(tc.expectedName, func(t *testing.T) { + header, err := r.Next() + require.NoError(t, err) + assert.Equal(t, tc.expectedTypeflag, header.Typeflag) + assert.Equal(t, tc.expectedName, header.Name) + assert.Equal(t, tc.expectedMode, header.Mode) + assert.Equal(t, tc.expectedLinkname, header.Linkname) + assert.Equal(t, int64(len(tc.expectedContents)), header.Size) + if tc.expectedContents != nil { + actualContents, err := ioutil.ReadAll(r) + require.NoError(t, err) + assert.Equal(t, tc.expectedContents, actualContents) + } + }) + } + _, err = r.Next() + assert.Equal(t, io.EOF, err) +} diff --git a/v2/internal/chezmoi/tomlserializationformat.go b/v2/internal/chezmoi/tomlserializationformat.go new file mode 100644 index 000000000000..75e2a2237925 --- /dev/null +++ b/v2/internal/chezmoi/tomlserializationformat.go @@ -0,0 +1,24 @@ +package chezmoi + +import "github.com/pelletier/go-toml" + +type tomlSerializationFormat struct{} + +// TOMLSerializationFormat is the TOML serialization format. +var TOMLSerializationFormat tomlSerializationFormat + +func (tomlSerializationFormat) Name() string { + return "toml" +} + +func (tomlSerializationFormat) Serialize(data interface{}) ([]byte, error) { + return toml.Marshal(data) +} + +func (tomlSerializationFormat) Deserialize(data []byte) (interface{}, error) { + var result interface{} + if err := toml.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} diff --git a/v2/internal/chezmoi/tomlserializationformat_test.go b/v2/internal/chezmoi/tomlserializationformat_test.go new file mode 100644 index 000000000000..2a6fed104ca5 --- /dev/null +++ b/v2/internal/chezmoi/tomlserializationformat_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ SerializationFormat = TOMLSerializationFormat diff --git a/v2/internal/chezmoi/yamlserializationformat.go b/v2/internal/chezmoi/yamlserializationformat.go new file mode 100644 index 000000000000..1c2dee7d7283 --- /dev/null +++ b/v2/internal/chezmoi/yamlserializationformat.go @@ -0,0 +1,24 @@ +package chezmoi + +import "gopkg.in/yaml.v2" + +type yamlSerializationFormat struct{} + +// YAMLSerializationFormat is the YAML serialization format. +var YAMLSerializationFormat yamlSerializationFormat + +func (yamlSerializationFormat) Name() string { + return "yaml" +} + +func (yamlSerializationFormat) Serialize(data interface{}) ([]byte, error) { + return yaml.Marshal(data) +} + +func (yamlSerializationFormat) Deserialize(data []byte) (interface{}, error) { + var result interface{} + if err := yaml.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} diff --git a/v2/internal/chezmoi/yamlserializationformat_test.go b/v2/internal/chezmoi/yamlserializationformat_test.go new file mode 100644 index 000000000000..bfd35f7ab981 --- /dev/null +++ b/v2/internal/chezmoi/yamlserializationformat_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ SerializationFormat = YAMLSerializationFormat From 3d03b52f0beeab7454d15d74d6abee316386f43d Mon Sep 17 00:00:00 2001 From: Tom Payne Date: Sun, 10 May 2020 18:10:36 +0100 Subject: [PATCH 2/4] snapshot --- v2/cmd/config.go | 257 ++++++++++++++++++++++++++++++++++++ v2/cmd/data.go | 41 ++++++ v2/cmd/data_linux.go | 101 ++++++++++++++ v2/cmd/data_linux_test.go | 200 ++++++++++++++++++++++++++++ v2/cmd/data_notlinux.go | 13 ++ v2/cmd/permvalue.go | 24 ++++ v2/cmd/permvalue_test.go | 20 +++ v2/cmd/root.go | 64 +++++++++ v2/cmd/util_posix.go | 23 ++++ v2/cmd/util_posix_test.go | 7 + v2/cmd/util_windows.go | 32 +++++ v2/cmd/util_windows_test.go | 9 ++ v2/main.go | 36 +++++ 13 files changed, 827 insertions(+) create mode 100644 v2/cmd/config.go create mode 100644 v2/cmd/data.go create mode 100644 v2/cmd/data_linux.go create mode 100644 v2/cmd/data_linux_test.go create mode 100644 v2/cmd/data_notlinux.go create mode 100644 v2/cmd/permvalue.go create mode 100644 v2/cmd/permvalue_test.go create mode 100644 v2/cmd/root.go create mode 100644 v2/cmd/util_posix.go create mode 100644 v2/cmd/util_posix_test.go create mode 100644 v2/cmd/util_windows.go create mode 100644 v2/cmd/util_windows_test.go create mode 100644 v2/main.go diff --git a/v2/cmd/config.go b/v2/cmd/config.go new file mode 100644 index 000000000000..76273a2c86c6 --- /dev/null +++ b/v2/cmd/config.go @@ -0,0 +1,257 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + "os/user" + "regexp" + "runtime" + "strings" + "text/template" + "unicode" + + "github.com/Masterminds/sprig" + "github.com/spf13/cobra" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-xdg/v3" + + "github.com/twpayne/chezmoi/v2/internal/chezmoi" +) + +type sourceVCSConfig struct { + Command string + AutoCommit bool + AutoPush bool + Init interface{} + Pull interface{} +} + +type templateConfig struct { + funcs template.FuncMap + Options []string +} + +// A Config represents a configuration. +type Config struct { + configFile string + err error + fs vfs.FS + system chezmoi.System + SourceDir string + DestDir string + Umask permValue + DryRun bool + Follow bool + Remove bool + Verbose bool + Color string + Debug bool + SourceVCS sourceVCSConfig + Data map[string]interface{} + Template templateConfig + scriptStateBucket []byte + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + bds *xdg.BaseDirectorySpecification +} + +// A configOption sets and option on a Config. +type configOption func(*Config) + +var ( + wellKnownAbbreviations = map[string]struct{}{ + "ANSI": {}, + "CPE": {}, + "ID": {}, + "URL": {}, + } + + identifierRegexp = regexp.MustCompile(`\A[\pL_][\pL\p{Nd}_]*\z`) + whitespaceRegexp = regexp.MustCompile(`\s+`) + + assets = make(map[string][]byte) +) + +// newConfig creates a new Config with the given options. +func newConfig(options ...configOption) *Config { + c := &Config{ + Umask: permValue(getUmask()), + Color: "auto", + SourceVCS: sourceVCSConfig{ + Command: "git", + }, + Template: templateConfig{ + funcs: sprig.TxtFuncMap(), + Options: chezmoi.DefaultTemplateOptions, + }, + scriptStateBucket: []byte("script"), + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + } + for _, option := range options { + option(c) + } + return c +} + +func (c *Config) addTemplateFunc(key string, value interface{}) { + if c.Template.funcs == nil { + c.Template.funcs = make(template.FuncMap) + } + if _, ok := c.Template.funcs[key]; ok { + panic(fmt.Sprintf("Config.addTemplateFunc: %s already defined", key)) + } + c.Template.funcs[key] = value +} + +// ensureNoError ensures that no error was encountered when loading c. +func (c *Config) ensureNoError(cmd *cobra.Command, args []string) error { + if c.err != nil { + return errors.New("config contains errors, aborting") + } + return nil +} + +func (c *Config) getDefaultData() (map[string]interface{}, error) { + data := map[string]interface{}{ + "arch": runtime.GOARCH, + "os": runtime.GOOS, + "sourceDir": c.SourceDir, + } + + currentUser, err := user.Current() + if err != nil { + return nil, err + } + data["username"] = currentUser.Username + + // user.LookupGroupId is generally unreliable: + // + // If CGO is enabled, then this uses an underlying C library call (e.g. + // getgrgid_r on Linux) and is trustworthy, except on recent versions of Go + // on Android, where LookupGroupId is not implemented. + // + // If CGO is disabled then the fallback implementation only searches + // /etc/group, which is typically empty if an external directory service is + // being used, and so the lookup fails. + // + // So, only set group if user.LookupGroupId does not return an error. + group, err := user.LookupGroupId(currentUser.Gid) + if err == nil { + data["group"] = group.Name + } + + homedir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + data["homedir"] = homedir + + hostname, err := os.Hostname() + if err != nil { + return nil, err + } + data["fullHostname"] = hostname + data["hostname"] = strings.SplitN(hostname, ".", 2)[0] + + osRelease, err := getOSRelease(c.fs) + if err == nil { + if osRelease != nil { + data["osRelease"] = upperSnakeCaseToCamelCaseMap(osRelease) + } + } else if !os.IsNotExist(err) { + return nil, err + } + + kernelInfo, err := getKernelInfo(c.fs) + if err == nil && kernelInfo != nil { + data["kernel"] = kernelInfo + } else if err != nil { + return nil, err + } + + return data, nil +} + +func (c *Config) getEditor() (string, []string) { + editor := os.Getenv("VISUAL") + if editor == "" { + editor = os.Getenv("EDITOR") + } + if editor == "" { + editor = "vi" + } + components := whitespaceRegexp.Split(editor, -1) + return components[0], components[1:] +} + +// isWellKnownAbbreviation returns true if word is a well known abbreviation. +func isWellKnownAbbreviation(word string) bool { + _, ok := wellKnownAbbreviations[word] + return ok +} + +func panicOnError(err error) { + if err != nil { + panic(err) + } +} + +// titilize returns s, titilized. +func titilize(s string) string { + if s == "" { + return s + } + runes := []rune(s) + return string(append([]rune{unicode.ToTitle(runes[0])}, runes[1:]...)) +} + +// upperSnakeCaseToCamelCase converts a string in UPPER_SNAKE_CASE to +// camelCase. +func upperSnakeCaseToCamelCase(s string) string { + words := strings.Split(s, "_") + for i, word := range words { + if i == 0 { + words[i] = strings.ToLower(word) + } else if !isWellKnownAbbreviation(word) { + words[i] = titilize(strings.ToLower(word)) + } + } + return strings.Join(words, "") +} + +// upperSnakeCaseToCamelCaseKeys returns m with all keys converted from +// UPPER_SNAKE_CASE to camelCase. +func upperSnakeCaseToCamelCaseMap(m map[string]string) map[string]string { + result := make(map[string]string) + for k, v := range m { + result[upperSnakeCaseToCamelCase(k)] = v + } + return result +} + +// validateKeys ensures that all keys in data match re. +func validateKeys(data interface{}, re *regexp.Regexp) error { + switch data := data.(type) { + case map[string]interface{}: + for key, value := range data { + if !re.MatchString(key) { + return fmt.Errorf("invalid key: %q", 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/v2/cmd/data.go b/v2/cmd/data.go new file mode 100644 index 000000000000..3da8b2426ad9 --- /dev/null +++ b/v2/cmd/data.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +type dataCmdConfig struct { + format string +} + +var dataCmd = &cobra.Command{ + Use: "data", + Args: cobra.NoArgs, + Short: "Print the template data", + Long: mustGetLongHelp("data"), + Example: getExample("data"), + PreRunE: config.ensureNoError, + RunE: config.runDataCmd, +} + +func init() { + rootCmd.AddCommand(dataCmd) + + persistentFlags := dataCmd.PersistentFlags() + persistentFlags.StringVarP(&config.data.format, "format", "f", "json", "format (JSON, TOML, or YAML)") +} + +func (c *Config) runDataCmd(cmd *cobra.Command, args []string) error { + format, ok := formatMap[strings.ToLower(c.data.format)] + if !ok { + return fmt.Errorf("%s: unknown format", c.data.format) + } + data, err := c.getData() + if err != nil { + return err + } + return format(c.Stdout, data) +} diff --git a/v2/cmd/data_linux.go b/v2/cmd/data_linux.go new file mode 100644 index 000000000000..14eb0835d52e --- /dev/null +++ b/v2/cmd/data_linux.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "unicode" + + "github.com/twpayne/go-vfs" +) + +func getKernelInfo(fs vfs.FS) (map[string]string, error) { + const procSysKernel = "/proc/sys/kernel" + + info, err := fs.Stat(procSysKernel) + switch { + case os.IsNotExist(err): + return nil, nil + case os.IsPermission(err): + return nil, nil + case !info.Mode().IsDir(): + return nil, nil + } + + kernelInfo := make(map[string]string) + for _, filename := range []string{ + "osrelease", + "ostype", + "version", + } { + data, err := fs.ReadFile(filepath.Join(procSysKernel, filename)) + switch { + case os.IsNotExist(err): + continue + case os.IsPermission(err): + continue + case err != nil: + return nil, err + } + kernelInfo[filename] = string(bytes.TrimSpace(data)) + } + return kernelInfo, nil +} + +// getOSRelease returns the operating system identification data as defined by +// https://www.freedesktop.org/software/systemd/man/os-release.html. +func getOSRelease(fs vfs.FS) (map[string]string, error) { + for _, filename := range []string{"/usr/lib/os-release", "/etc/os-release"} { + f, err := fs.Open(filename) + if os.IsNotExist(err) { + continue + } else if err != nil { + return nil, err + } + defer f.Close() + m, err := parseOSRelease(f) + if err != nil { + return nil, err + } + return m, nil + } + return nil, os.ErrNotExist +} + +// maybeUnquote removes quotation marks around s. +func maybeUnquote(s string) string { + // Try to unquote. + if s, err := strconv.Unquote(s); err == nil { + return s + } + // Otherwise return s, unchanged. + return s +} + +// parseOSRelease parses operating system identification data from r as defined +// by https://www.freedesktop.org/software/systemd/man/os-release.html. +func parseOSRelease(r io.Reader) (map[string]string, error) { + result := make(map[string]string) + s := bufio.NewScanner(r) + for s.Scan() { + // trim all leading whitespace, but not necessarily trailing whitespace + token := strings.TrimLeftFunc(s.Text(), unicode.IsSpace) + // if the line is empty or starts with #, skip + if len(token) == 0 || token[0] == '#' { + continue + } + fields := strings.SplitN(token, "=", 2) + if len(fields) != 2 { + return nil, fmt.Errorf("cannot parse %q", token) + } + key := fields[0] + value := maybeUnquote(fields[1]) + result[key] = value + } + return result, s.Err() +} diff --git a/v2/cmd/data_linux_test.go b/v2/cmd/data_linux_test.go new file mode 100644 index 000000000000..d4b37541e95e --- /dev/null +++ b/v2/cmd/data_linux_test.go @@ -0,0 +1,200 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs/vfst" +) + +func TestGetKernelInfo(t *testing.T) { + for _, tc := range []struct { + name string + root interface{} + expectedKernelInfo map[string]string + }{ + { + name: "windows_services_for_linux", + root: map[string]interface{}{ + "/proc/sys/kernel": map[string]interface{}{ + "osrelease": "4.19.81-microsoft-standard\n", + "ostype": "Linux\n", + "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)\n", + }, + }, + expectedKernelInfo: map[string]string{ + "osrelease": "4.19.81-microsoft-standard", + "ostype": "Linux", + "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)", + }, + }, + { + name: "debian_version_only", + root: map[string]interface{}{ + "/proc/sys/kernel": map[string]interface{}{ + "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)\n", + }, + }, + expectedKernelInfo: map[string]string{ + "version": "#1 SMP Debian 5.2.9-2 (2019-08-21)", + }, + }, + { + name: "proc_sys_kernel_missing", + root: map[string]interface{}{ + "/proc/sys": &vfst.Dir{Perm: 0o755}, + }, + expectedKernelInfo: nil, + }, + } { + t.Run(tc.name, func(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(tc.root) + require.NoError(t, err) + defer cleanup() + kernelInfo, err := getKernelInfo(fs) + assert.NoError(t, err) + assert.Equal(t, tc.expectedKernelInfo, kernelInfo) + }) + } +} + +func TestGetOSRelease(t *testing.T) { + for _, tc := range []struct { + name string + root map[string]interface{} + want map[string]string + }{ + { + name: "fedora", + root: map[string]interface{}{ + "/etc/os-release": `NAME=Fedora +VERSION="17 (Beefy Miracle)" +ID=fedora +VERSION_ID=17 +PRETTY_NAME="Fedora 17 (Beefy Miracle)" +ANSI_COLOR="0;34" +CPE_NAME="cpe:/o:fedoraproject:fedora:17" +HOME_URL="https://fedoraproject.org/" +BUG_REPORT_URL="https://bugzilla.redhat.com/"`, + }, + want: map[string]string{ + "NAME": "Fedora", + "VERSION": "17 (Beefy Miracle)", + "ID": "fedora", + "VERSION_ID": "17", + "PRETTY_NAME": "Fedora 17 (Beefy Miracle)", + "ANSI_COLOR": "0;34", + "CPE_NAME": "cpe:/o:fedoraproject:fedora:17", + "HOME_URL": "https://fedoraproject.org/", + "BUG_REPORT_URL": "https://bugzilla.redhat.com/", + }, + }, + { + name: "ubuntu", + root: map[string]interface{}{ + "/usr/lib/os-release": `NAME="Ubuntu" +VERSION="18.04.1 LTS (Bionic Beaver)" +ID=ubuntu +ID_LIKE=debian +PRETTY_NAME="Ubuntu 18.04.1 LTS" +VERSION_ID="18.04" +HOME_URL="https://www.ubuntu.com/" +SUPPORT_URL="https://help.ubuntu.com/" +BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" +PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" +VERSION_CODENAME=bionic +UBUNTU_CODENAME=bionic`, + }, + want: map[string]string{ + "NAME": "Ubuntu", + "VERSION": "18.04.1 LTS (Bionic Beaver)", + "ID": "ubuntu", + "ID_LIKE": "debian", + "PRETTY_NAME": "Ubuntu 18.04.1 LTS", + "VERSION_ID": "18.04", + "HOME_URL": "https://www.ubuntu.com/", + "SUPPORT_URL": "https://help.ubuntu.com/", + "BUG_REPORT_URL": "https://bugs.launchpad.net/ubuntu/", + "PRIVACY_POLICY_URL": "https://www.ubuntu.com/legal/terms-and-policies/privacy-policy", + "VERSION_CODENAME": "bionic", + "UBUNTU_CODENAME": "bionic", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(tc.root) + require.NoError(t, err) + defer cleanup() + got, gotErr := getOSRelease(fs) + assert.NoError(t, gotErr) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestParseOSRelease(t *testing.T) { + for _, tc := range []struct { + s string + want map[string]string + }{ + { + s: `NAME=Fedora +VERSION="17 (Beefy Miracle)" +ID=fedora +VERSION_ID=17 +PRETTY_NAME="Fedora 17 (Beefy Miracle)" +ANSI_COLOR="0;34" +CPE_NAME="cpe:/o:fedoraproject:fedora:17" +HOME_URL="https://fedoraproject.org/" +BUG_REPORT_URL="https://bugzilla.redhat.com/"`, + want: map[string]string{ + "NAME": "Fedora", + "VERSION": "17 (Beefy Miracle)", + "ID": "fedora", + "VERSION_ID": "17", + "PRETTY_NAME": "Fedora 17 (Beefy Miracle)", + "ANSI_COLOR": "0;34", + "CPE_NAME": "cpe:/o:fedoraproject:fedora:17", + "HOME_URL": "https://fedoraproject.org/", + "BUG_REPORT_URL": "https://bugzilla.redhat.com/", + }, + }, + { + s: `NAME="Ubuntu" +VERSION="18.04.1 LTS (Bionic Beaver)" +ID=ubuntu +ID_LIKE=debian +PRETTY_NAME="Ubuntu 18.04.1 LTS" +VERSION_ID="18.04" +HOME_URL="https://www.ubuntu.com/" +SUPPORT_URL="https://help.ubuntu.com/" +BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" +PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" +# comment + + # comment +VERSION_CODENAME=bionic +UBUNTU_CODENAME=bionic`, + want: map[string]string{ + "NAME": "Ubuntu", + "VERSION": "18.04.1 LTS (Bionic Beaver)", + "ID": "ubuntu", + "ID_LIKE": "debian", + "PRETTY_NAME": "Ubuntu 18.04.1 LTS", + "VERSION_ID": "18.04", + "HOME_URL": "https://www.ubuntu.com/", + "SUPPORT_URL": "https://help.ubuntu.com/", + "BUG_REPORT_URL": "https://bugs.launchpad.net/ubuntu/", + "PRIVACY_POLICY_URL": "https://www.ubuntu.com/legal/terms-and-policies/privacy-policy", + "VERSION_CODENAME": "bionic", + "UBUNTU_CODENAME": "bionic", + }, + }, + } { + got, gotErr := parseOSRelease(bytes.NewBufferString(tc.s)) + assert.NoError(t, gotErr) + assert.Equal(t, tc.want, got) + } +} diff --git a/v2/cmd/data_notlinux.go b/v2/cmd/data_notlinux.go new file mode 100644 index 000000000000..6dc54b5e39a5 --- /dev/null +++ b/v2/cmd/data_notlinux.go @@ -0,0 +1,13 @@ +// +build !linux + +package cmd + +import "github.com/twpayne/go-vfs" + +func getKernelInfo(fs vfs.FS) (map[string]string, error) { + return nil, nil +} + +func getOSRelease(fs vfs.FS) (map[string]string, error) { + return nil, nil +} diff --git a/v2/cmd/permvalue.go b/v2/cmd/permvalue.go new file mode 100644 index 000000000000..3b2371426be0 --- /dev/null +++ b/v2/cmd/permvalue.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + "strconv" +) + +// An permValue is an int that is scanned and printed in octal. It implements +// the pflag.Value interface for use as a command line flag. +type permValue int + +func (p *permValue) Set(s string) error { + v, err := strconv.ParseInt(s, 8, 64) + *p = permValue(v) + return err +} + +func (p *permValue) String() string { + return fmt.Sprintf("%03o", *p) +} + +func (p *permValue) Type() string { + return "int" +} diff --git a/v2/cmd/permvalue_test.go b/v2/cmd/permvalue_test.go new file mode 100644 index 000000000000..4e8e0aa2c2f0 --- /dev/null +++ b/v2/cmd/permvalue_test.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPermValue(t *testing.T) { + for s, expected := range map[string]string{ + "0": "000", + "644": "644", + "755": "755", + } { + var p permValue + assert.NoError(t, p.Set(s)) + assert.Equal(t, expected, p.String()) + assert.Equal(t, "int", p.Type()) + } +} diff --git a/v2/cmd/root.go b/v2/cmd/root.go new file mode 100644 index 000000000000..1439075edea4 --- /dev/null +++ b/v2/cmd/root.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "errors" + "strings" + + "github.com/coreos/go-semver/semver" + "github.com/spf13/cobra" +) + +var config = newConfig() + +// Version information. +var ( + VersionStr string + Commit string + Date string + BuiltBy string + Version *semver.Version +) + +var rootCmd = &cobra.Command{ + Use: "chezmoi", + Short: "Manage your dotfiles across multiple machines, securely", + SilenceErrors: true, + SilenceUsage: true, + // PersistentPreRunE: config.persistentPreRunRootE, +} + +var ( + errExitFailure = errors.New("") + initErr error +) + +// Execute executes the root command. +func Execute() error { + if initErr != nil { + return initErr + } + + var versionComponents []string + if VersionStr != "" { + var err error + Version, err = semver.NewVersion(strings.TrimPrefix(VersionStr, "v")) + if err != nil { + return err + } + versionComponents = append(versionComponents, VersionStr) + } else { + versionComponents = append(versionComponents, "dev") + } + if Commit != "" { + versionComponents = append(versionComponents, "commit "+Commit) + } + if Date != "" { + versionComponents = append(versionComponents, "built at "+Date) + } + if BuiltBy != "" { + versionComponents = append(versionComponents, "built by "+BuiltBy) + } + rootCmd.Version = strings.Join(versionComponents, ", ") + + return rootCmd.Execute() +} diff --git a/v2/cmd/util_posix.go b/v2/cmd/util_posix.go new file mode 100644 index 000000000000..92c38f409afb --- /dev/null +++ b/v2/cmd/util_posix.go @@ -0,0 +1,23 @@ +// +build !windows + +package cmd + +import ( + "io" + "syscall" +) + +// enableVirtualTerminalProcessingOnWindows does nothing on POSIX systems. +func enableVirtualTerminalProcessingOnWindows(w io.Writer) error { + return nil +} + +func getUmask() int { + umask := syscall.Umask(0) + syscall.Umask(umask) + return umask +} + +func trimExecutableSuffix(s string) string { + return s +} diff --git a/v2/cmd/util_posix_test.go b/v2/cmd/util_posix_test.go new file mode 100644 index 000000000000..df968515fd39 --- /dev/null +++ b/v2/cmd/util_posix_test.go @@ -0,0 +1,7 @@ +//+build !windows + +package cmd + +func lines(s string) string { + return s +} diff --git a/v2/cmd/util_windows.go b/v2/cmd/util_windows.go new file mode 100644 index 000000000000..dff4a77581c0 --- /dev/null +++ b/v2/cmd/util_windows.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "io" + "os" + "strings" + + "golang.org/x/sys/windows" +) + +// enableVirtualTerminalProcessingOnWindows enables virtual terminal processing +// on Windows. See +// https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences. +func enableVirtualTerminalProcessingOnWindows(w io.Writer) error { + f, ok := w.(*os.File) + if !ok { + return nil + } + var dwMode uint32 + if err := windows.GetConsoleMode(windows.Handle(f.Fd()), &dwMode); err != nil { + return nil // Ignore error in the case that fd is not a terminal. + } + return windows.SetConsoleMode(windows.Handle(f.Fd()), dwMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) +} + +func getUmask() int { + return 0 +} + +func trimExecutableSuffix(s string) string { + return strings.TrimSuffix(s, ".exe") +} diff --git a/v2/cmd/util_windows_test.go b/v2/cmd/util_windows_test.go new file mode 100644 index 000000000000..750fedf70671 --- /dev/null +++ b/v2/cmd/util_windows_test.go @@ -0,0 +1,9 @@ +//+build windows + +package cmd + +import "strings" + +func lines(s string) string { + return strings.Replace(s, "\n", "\r\n", -1) +} diff --git a/v2/main.go b/v2/main.go new file mode 100644 index 000000000000..5e814649fb73 --- /dev/null +++ b/v2/main.go @@ -0,0 +1,36 @@ +//go:generate go run ../internal/generate-assets -o cmd/docs.gen.go -tags=!noembeddocs ../docs/CHANGES.md ../docs/CONTRIBUTING.md ../docs/FAQ.md ../docs/HOWTO.md ../docs/INSTALL.md ../docs/MEDIA.md ../docs/QUICKSTART.md ../docs/REFERENCE.md +//go:generate go run ../internal/generate-assets -o cmd/templates.gen.go ../assets/templates/COMMIT_MESSAGE.tmpl +//go:generate go run ../internal/generate-helps -o cmd/helps.gen.go -i ../docs/REFERENCE.md + +package main + +import ( + "fmt" + "os" + + "github.com/twpayne/chezmoi/v2/cmd" +) + +var ( + version = "" + commit = "" + date = "" + builtBy = "" +) + +func run() error { + cmd.VersionStr = version + cmd.Commit = commit + cmd.Date = date + cmd.BuiltBy = builtBy + return cmd.Execute() +} + +func main() { + if err := run(); err != nil { + if s := err.Error(); s != "" { + fmt.Printf("chezmoi: %s\n", s) + } + os.Exit(1) + } +} From b25c62c5a34bbc70a538fa5d74861913cd9d9a68 Mon Sep 17 00:00:00 2001 From: Tom Payne Date: Sun, 10 May 2020 18:17:04 +0100 Subject: [PATCH 3/4] snapshot --- v2/cmd/data.go | 41 - v2/cmd/docs.gen.go | 2615 +++++++++++++++++++++++++++++++++++++++ v2/cmd/helps.gen.go | 508 ++++++++ v2/cmd/root.go | 23 + v2/cmd/templates.gen.go | 31 + v2/main.go | 4 +- 6 files changed, 3179 insertions(+), 43 deletions(-) delete mode 100644 v2/cmd/data.go create mode 100644 v2/cmd/docs.gen.go create mode 100644 v2/cmd/helps.gen.go create mode 100644 v2/cmd/templates.gen.go diff --git a/v2/cmd/data.go b/v2/cmd/data.go deleted file mode 100644 index 3da8b2426ad9..000000000000 --- a/v2/cmd/data.go +++ /dev/null @@ -1,41 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" -) - -type dataCmdConfig struct { - format string -} - -var dataCmd = &cobra.Command{ - Use: "data", - Args: cobra.NoArgs, - Short: "Print the template data", - Long: mustGetLongHelp("data"), - Example: getExample("data"), - PreRunE: config.ensureNoError, - RunE: config.runDataCmd, -} - -func init() { - rootCmd.AddCommand(dataCmd) - - persistentFlags := dataCmd.PersistentFlags() - persistentFlags.StringVarP(&config.data.format, "format", "f", "json", "format (JSON, TOML, or YAML)") -} - -func (c *Config) runDataCmd(cmd *cobra.Command, args []string) error { - format, ok := formatMap[strings.ToLower(c.data.format)] - if !ok { - return fmt.Errorf("%s: unknown format", c.data.format) - } - data, err := c.getData() - if err != nil { - return err - } - return format(c.Stdout, data) -} diff --git a/v2/cmd/docs.gen.go b/v2/cmd/docs.gen.go new file mode 100644 index 000000000000..7887489add97 --- /dev/null +++ b/v2/cmd/docs.gen.go @@ -0,0 +1,2615 @@ +// Code generated by github.com/twpayne/chezmoi/internal/generate-assets. DO NOT EDIT. +// +build !noembeddocs + +package cmd + +func init() { + assets["docs/CHANGES.md"] = []byte("" + + "# chezmoi Changes\n" + + "\n" + + "\n" + + "* [Upcoming](#upcoming)\n" + + " * [Default diff format changing from `chezmoi` to `git`.](#default-diff-format-changing-from-chezmoi-to-git)\n" + + " * [`gpgRecipient` config variable changing to `gpg.recipient`](#gpgrecipient-config-variable-changing-to-gpgrecipient)\n" + + "\n" + + "## Upcoming\n" + + "\n" + + "### Default diff format changing from `chezmoi` to `git`.\n" + + "\n" + + "Currently chezmoi outputs diffs in its own format, containing a mix of unified\n" + + "diffs and shell commands. This will be replaced with a [git format\n" + + "diff](https://git-scm.com/docs/diff-format) in version 2.0.0.\n" + + "\n" + + "### `gpgRecipient` config variable changing to `gpg.recipient`\n" + + "\n" + + "The `gpgRecipient` config variable is changing to `gpg.recipient`. To update,\n" + + "change your config from:\n" + + "\n" + + " gpgRecipient = \"...\"\n" + + "\n" + + "to:\n" + + "\n" + + " [gpg]\n" + + " recipient = \"...\"\n" + + "\n" + + "Support for the `gpgRecipient` config variable will be removed in version 2.0.0.\n") + assets["docs/CONTRIBUTING.md"] = []byte("" + + "# chezmoi Contributing Guide\n" + + "\n" + + "\n" + + "* [Getting started](#getting-started)\n" + + "* [Developing locally](#developing-locally)\n" + + "* [Generated code](#generated-code)\n" + + "* [Contributing changes](#contributing-changes)\n" + + "* [Managing releases](#managing-releases)\n" + + "* [Packaging](#packaging)\n" + + "* [Updating the website](#updating-the-website)\n" + + "\n" + + "## Getting started\n" + + "\n" + + "chezmoi is written in [Go](https://golang.org) and development happens on\n" + + "[GitHub](https://github.com). The rest of this document assumes that you've\n" + + "checked out chezmoi locally.\n" + + "\n" + + "## Developing locally\n" + + "\n" + + "chezmoi requires Go 1.13 or later and Go modules enabled. Enable Go modules by\n" + + "setting the environment variable `GO111MODULE=on`.\n" + + "\n" + + "chezmoi is a standard Go project, using standard Go tooling, with a few extra\n" + + "tools. Ensure that these extra tools are installed with:\n" + + "\n" + + " make ensure-tools\n" + + "\n" + + "Build chezmoi:\n" + + "\n" + + " go build .\n" + + "\n" + + "Run all tests:\n" + + "\n" + + " go test ./...\n" + + "\n" + + "Run chezmoi:\n" + + "\n" + + " go run .\n" + + "\n" + + "## Generated code\n" + + "\n" + + "chezmoi generates help text, shell completions, embedded files, and the website\n" + + "from a single source of truth. You must run\n" + + "\n" + + " go generate\n" + + "\n" + + "if you change includes any of the following:\n" + + "\n" + + "* Modify any documentation in the `docs/` directory.\n" + + "* Modify any files in the `assets/templates/` directory.\n" + + "* Add or modify a command.\n" + + "* Add or modify a command's flags.\n" + + "\n" + + "chezmoi's continuous integration verifies that all generated files are up to\n" + + "date. Changes to generated files should be included in the commit that modifies\n" + + "the source of truth.\n" + + "\n" + + "## Contributing changes\n" + + "\n" + + "Bug reports, bug fixes, and documentation improvements are always welcome.\n" + + "Please [open an issue](https://github.com/twpayne/chezmoi/issues/new/choose) or\n" + + "[create a pull\n" + + "request](https://help.github.com/en/articles/creating-a-pull-request) with your\n" + + "report, fix, or improvement.\n" + + "\n" + + "If you want to make a more significant change, please first [open an\n" + + "issue](https://github.com/twpayne/chezmoi/issues/new/choose) to discuss the\n" + + "change that you want to make. Dave Cheney gives a [good\n" + + "rationale](https://dave.cheney.net/2019/02/18/talk-then-code) as to why this is\n" + + "important.\n" + + "\n" + + "All changes are made via pull requests. In your pull request, please make sure\n" + + "that:\n" + + "\n" + + "* All existing tests pass.\n" + + "\n" + + "* There are appropriate additional tests that demonstrate that your PR works as\n" + + " intended.\n" + + "\n" + + "* The documentation is updated, if necessary. For new features you should add an\n" + + " entry in `docs/HOWTO.md` and a complete description in `docs/REFERENCE.md`.\n" + + "\n" + + "* All generated files are up to date. You can ensure this by running `go\n" + + " generate` and including any modified files in your commit.\n" + + "\n" + + "* The code is correctly formatted, according to\n" + + " [`gofumports`](https://mvdan.cc/gofumpt/gofumports). You can ensure this by\n" + + " running `make format`.\n" + + "\n" + + "* The code passes [`golangci-lint`](https://github.com/golangci/golangci-lint).\n" + + " You can ensure this by running `make lint`.\n" + + "\n" + + "* The commit messages match chezmoi's convention, specifically that they begin\n" + + " with a capitalized verb in the imperative and give a short description of what\n" + + " the commit does. Detailed information or justification can be optionally\n" + + " included in the body of the commit message.\n" + + "\n" + + "* Commits are logically separate, with no merge or \"fixup\" commits.\n" + + "\n" + + "* The branch applies cleanly to `master`.\n" + + "\n" + + "## Managing releases\n" + + "\n" + + "Releases are managed with [`goreleaser`](https://goreleaser.com/).\n" + + "\n" + + "To create a new release, push a tag, e.g.:\n" + + "\n" + + " git tag -a v0.1.0 -m \"First release\"\n" + + " git push origin v0.1.0\n" + + "\n" + + "To build a test release, without publishing, run:\n" + + "\n" + + " make test-release\n" + + "\n" + + "## Packaging\n" + + "\n" + + "If you're packaging chezmoi for an operating system or distribution:\n" + + "\n" + + "* Please set the version number, git commit, and build time in the binary. This\n" + + " greatly assists debugging when end users report problems or ask for help. You\n" + + " can do this by passing the following flags to the Go linker:\n" + + "\n" + + " ```\n" + + " -X main.version=$VERSION\n" + + " -X main.commit=$COMMIT\n" + + " -X main.date=$DATE\n" + + " -X main.builtBy=$BUILT_BY\n" + + " ```\n" + + "\n" + + " `$VERSION` should be the chezmoi version, e.g. `1.7.3`. Any `v` prefix is\n" + + " optional and will be stripped, so you can pass the git tag in directly.\n" + + "\n" + + " `$COMMIT` should be the full git commit hash at which chezmoi is built, e.g.\n" + + " `4d678ce6850c9d81c7ab2fe0d8f20c1547688b91`.\n" + + "\n" + + " `$DATE` should be the date of the build in RFC3339 format, e.g.\n" + + " `2019-11-23T18:29:25Z`.\n" + + "\n" + + " `$BUILT_BY` should be a string indicating what mechanism was used to build the\n" + + " binary, e.g. `goreleaser`.\n" + + "\n" + + "* Please enable cgo, if possible. chezmoi can be built and run without cgo, but\n" + + " the `.chezmoi.username` and `.chezmoi.group` template variables may not be set\n" + + " correctly on some systems.\n" + + "\n" + + "* chezmoi includes a `docs` command which prints its documentation. By default,\n" + + " the docs are embedded in the binary. You can disable this behavior, and have\n" + + " chezmoi read its docs from the filesystem by building with the `noembeddocs`\n" + + " build tag and setting the directory where chezmoi can find them with the `-X\n" + + " github.com/twpayne/chezmoi/cmd.DocDir=$DOCDIR` linker flag. For example:\n" + + "\n" + + " ```\n" + + " go build -tags noembeddocs -ldflags \"-X github.com/twpayne/chezmoi/cmd.DocsDir=/usr/share/doc/chezmoi\" .\n" + + " ```\n" + + "\n" + + " To remove the `docs` command completely, use the `nodocs` build tag.\n" + + "\n" + + "* chezmoi includes an `upgrade` command which attempts to self-upgrade. You can\n" + + " remove this command completely by building chezmoi with the `noupgrade` build\n" + + " tag.\n" + + "\n" + + "* chezmoi includes shell completions in the `completions` directory. Please\n" + + " include these in the package and install them in the shell-appropriate\n" + + " directory, if possible.\n" + + "\n" + + "* If the instructions for installing chezmoi in chezmoi's [install\n" + + " guide](https://github.com/twpayne/chezmoi/blob/master/docs/INSTALL.md) are\n" + + " absent or incorrect, please open an issue or submit a PR to correct them.\n" + + "\n" + + "## Updating the website\n" + + "\n" + + "[The website](https://chezmoi.io) is generated with [Hugo](https://gohugo.io/)\n" + + "and served with [GitHub pages](https://pages.github.com/) from the [`gh-pages`\n" + + "branch](https://github.com/twpayne/chezmoi/tree/gh-pages) to GitHub.\n" + + "\n" + + "Before building the website, you must download the [Hugo Book\n" + + "Theme](https://github.com/alex-shpak/hugo-book) by running:\n" + + "\n" + + " git submodule update --init\n" + + "\n" + + "Test the website locally by running:\n" + + "\n" + + " ( cd chezmoi.io && hugo serve )\n" + + "\n" + + "and visit http://localhost:1313/.\n" + + "\n" + + "To build the website in a temporary directory, run:\n" + + "\n" + + " ( cd chezmoi.io && make )\n" + + "\n" + + "From here you can run\n" + + "\n" + + " git show\n" + + "\n" + + "to show changes and\n" + + "\n" + + " git push\n" + + "\n" + + "to push them. You can only push changes if you have write permissions to the\n" + + "chezmoi GitHub repo.\n") + assets["docs/FAQ.md"] = []byte("" + + "# chezmoi Frequently Asked Questions\n" + + "\n" + + "\n" + + "* [How can I quickly check for problems with chezmoi on my machine?](#how-can-i-quickly-check-for-problems-with-chezmoi-on-my-machine)\n" + + "* [What are the consequences of \"bare\" modifications to the target files? If my `.zshrc` is managed by chezmoi and I edit `~/.zshrc` without using `chezmoi edit`, what happens?](#what-are-the-consequences-of-bare-modifications-to-the-target-files-if-my-zshrc-is-managed-by-chezmoi-and-i-edit-zshrc-without-using-chezmoi-edit-what-happens)\n" + + "* [How can I tell what dotfiles in my home directory aren't managed by chezmoi? Is there an easy way to have chezmoi manage a subset of them?](#how-can-i-tell-what-dotfiles-in-my-home-directory-arent-managed-by-chezmoi-is-there-an-easy-way-to-have-chezmoi-manage-a-subset-of-them)\n" + + "* [How can I tell what dotfiles in my home directory are currently managed by chezmoi?](#how-can-i-tell-what-dotfiles-in-my-home-directory-are-currently-managed-by-chezmoi)\n" + + "* [If there's a mechanism in place for the above, is there also a way to tell chezmoi to ignore specific files or groups of files (e.g. by directory name or by glob)?](#if-theres-a-mechanism-in-place-for-the-above-is-there-also-a-way-to-tell-chezmoi-to-ignore-specific-files-or-groups-of-files-eg-by-directory-name-or-by-glob)\n" + + "* [If the target already exists, but is \"behind\" the source, can chezmoi be configured to preserve the target version before replacing it with one derived from the source?](#if-the-target-already-exists-but-is-behind-the-source-can-chezmoi-be-configured-to-preserve-the-target-version-before-replacing-it-with-one-derived-from-the-source)\n" + + "* [Once I've made a change to the source directory, how do I commit it?](#once-ive-made-a-change-to-the-source-directory-how-do-i-commit-it)\n" + + "* [How do I only run a script when a file has changed?](#how-do-i-only-run-a-script-when-a-file-has-changed)\n" + + "* [I've made changes to both the destination state and the source state that I want to keep. How can I keep them both?](#ive-made-changes-to-both-the-destination-state-and-the-source-state-that-i-want-to-keep-how-can-i-keep-them-both)\n" + + "* [Why does chezmoi convert all my template variables to lowercase?](#why-does-chezmoi-convert-all-my-template-variables-to-lowercase)\n" + + "* [chezmoi makes `~/.ssh/config` group writeable. How do I stop this?](#chezmoi-makes-sshconfig-group-writeable-how-do-i-stop-this)\n" + + "* [chezmoi's source file naming system cannot handle all possible filenames](#chezmois-source-file-naming-system-cannot-handle-all-possible-filenames)\n" + + "* [gpg encryption fails. What could be wrong?](#gpg-encryption-fails-what-could-be-wrong)\n" + + "* [I'm getting errors trying to build chezmoi from source](#im-getting-errors-trying-to-build-chezmoi-from-source)\n" + + "* [What inspired chezmoi?](#what-inspired-chezmoi)\n" + + "* [Can I use chezmoi to manage files outside my home directory?](#can-i-use-chezmoi-to-manage-files-outside-my-home-directory)\n" + + "* [Where does the name \"chezmoi\" come from?](#where-does-the-name-chezmoi-come-from)\n" + + "* [What other questions have been asked about chezmoi?](#what-other-questions-have-been-asked-about-chezmoi)\n" + + "* [Where do I ask a question that isn't answered here?](#where-do-i-ask-a-question-that-isnt-answered-here)\n" + + "* [I like chezmoi. How do I say thanks?](#i-like-chezmoi-how-do-i-say-thanks)\n" + + "\n" + + "## How can I quickly check for problems with chezmoi on my machine?\n" + + "\n" + + "Run:\n" + + "\n" + + " chezmoi doctor\n" + + "\n" + + "Anything `ok` is fine, anything `warning` is only a problem if you want to use\n" + + "the related feature, and anything `error` indicates a definite problem.\n" + + "\n" + + "## What are the consequences of \"bare\" modifications to the target files? If my `.zshrc` is managed by chezmoi and I edit `~/.zshrc` without using `chezmoi edit`, what happens?\n" + + "\n" + + "chezmoi will overwrite the file the next time you run `chezmoi apply`. Until you\n" + + "run `chezmoi apply` your modified `~/.zshrc` will remain in place.\n" + + "\n" + + "## How can I tell what dotfiles in my home directory aren't managed by chezmoi? Is there an easy way to have chezmoi manage a subset of them?\n" + + "\n" + + "`chezmoi unmanaged` will list everything not managed by chezmoi. You can add\n" + + "entire directories with `chezmoi add -r`.\n" + + "\n" + + "## How can I tell what dotfiles in my home directory are currently managed by chezmoi?\n" + + "\n" + + "`chezmoi managed` will list everything managed by chezmoi.\n" + + "\n" + + "## If there's a mechanism in place for the above, is there also a way to tell chezmoi to ignore specific files or groups of files (e.g. by directory name or by glob)?\n" + + "\n" + + "By default, chezmoi ignores everything that you haven't explicitly `chezmoi\n" + + "add`'ed. If have files in your source directory that you don't want added to\n" + + "your destination directory when you run `chezmoi apply` add their names to a\n" + + "file called `.chezmoiignore` in the source state.\n" + + "\n" + + "Patterns are supported, and the you can change what's ignored from machine to\n" + + "machine. The full usage and syntax is described in the [reference\n" + + "manual](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#chezmoiignore).\n" + + "\n" + + "## If the target already exists, but is \"behind\" the source, can chezmoi be configured to preserve the target version before replacing it with one derived from the source?\n" + + "\n" + + "Yes. Run `chezmoi add` will update the source state with the target. To see\n" + + "diffs of what would change, without actually changing anything, use `chezmoi\n" + + "diff`.\n" + + "\n" + + "## Once I've made a change to the source directory, how do I commit it?\n" + + "\n" + + "You have several options:\n" + + "\n" + + "* `chezmoi cd` opens a shell in the source directory, where you can run your\n" + + " usual version control commands, like `git add` and `git commit`.\n" + + "* `chezmoi git` and `chezmoi hg` run `git` and `hg` respectively in the source\n" + + " directory and pass extra arguments to the command. If you're passing any\n" + + " flags, you'll need to use `--` to prevent chezmoi from consuming them, for\n" + + " example `chezmoi git -- commit -m \"Update dotfiles\"`.\n" + + "* `chezmoi source` runs your configured version control system in your source\n" + + " directory. It works in the same way as the `chezmoi git` and `chezmoi hg`\n" + + " commands, but uses `sourceVCS.command`.\n" + + "\n" + + "## How do I only run a script when a file has changed?\n" + + "\n" + + "A common example of this is that you're using [Homebrew](https://brew.sh/) and\n" + + "have `.Brewfile` listing all the packages that you want installed and only want\n" + + "to run `brew bundle --global` when the contents of `.Brewfile` have changed.\n" + + "\n" + + "chezmoi has two types of scripts: scripts that run every time, and scripts that\n" + + "only run when their contents change. chezmoi does not have a mechanism to run a\n" + + "script when an arbitrary file has changed, but there are some ways to achieve\n" + + "the desired behavior:\n" + + "\n" + + "1. Have the script create `.Brewfile` instead of chezmoi, e.g. in your\n" + + " `run_once_install-packages`:\n" + + "\n" + + " ```sh\n" + + " #!/bin/sh\n" + + "\n" + + " cat > $HOME/.Brewfile <\n" + + "* [Use a hosted repo to manage your dotfiles across multiple machines](#use-a-hosted-repo-to-manage-your-dotfiles-across-multiple-machines)\n" + + "* [Pull the latest changes from your repo and apply them](#pull-the-latest-changes-from-your-repo-and-apply-them)\n" + + "* [Pull the latest changes from your repo and see what would change, without actually applying the changes](#pull-the-latest-changes-from-your-repo-and-see-what-would-change-without-actually-applying-the-changes)\n" + + "* [Automatically commit and push changes to your repo](#automatically-commit-and-push-changes-to-your-repo)\n" + + "* [Use templates to manage files that vary from machine to machine](#use-templates-to-manage-files-that-vary-from-machine-to-machine)\n" + + "* [Use completely separate config files on different machines](#use-completely-separate-config-files-on-different-machines)\n" + + "* [Create a config file on a new machine automatically](#create-a-config-file-on-a-new-machine-automatically)\n" + + "* [Have chezmoi create a directory, but ignore its contents](#have-chezmoi-create-a-directory-but-ignore-its-contents)\n" + + "* [Ensure that a target is removed](#ensure-that-a-target-is-removed)\n" + + "* [Include a subdirectory from another repository, like Oh My Zsh](#include-a-subdirectory-from-another-repository-like-oh-my-zsh)\n" + + "* [Handle configuration files which are externally modified](#handle-configuration-files-which-are-externally-modified)\n" + + "* [Keep data private](#keep-data-private)\n" + + " * [Use Bitwarden to keep your secrets](#use-bitwarden-to-keep-your-secrets)\n" + + " * [Use gopass to keep your secrets](#use-gopass-to-keep-your-secrets)\n" + + " * [Use gpg to keep your secrets](#use-gpg-to-keep-your-secrets)\n" + + " * [Use KeePassXC to keep your secrets](#use-keepassxc-to-keep-your-secrets)\n" + + " * [Use a keyring to keep your secrets](#use-a-keyring-to-keep-your-secrets)\n" + + " * [Use LastPass to keep your secrets](#use-lastpass-to-keep-your-secrets)\n" + + " * [Use 1Password to keep your secrets](#use-1password-to-keep-your-secrets)\n" + + " * [Use pass to keep your secrets](#use-pass-to-keep-your-secrets)\n" + + " * [Use Vault to keep your secrets](#use-vault-to-keep-your-secrets)\n" + + " * [Use a generic tool to keep your secrets](#use-a-generic-tool-to-keep-your-secrets)\n" + + " * [Use templates variables to keep your secrets](#use-templates-variables-to-keep-your-secrets)\n" + + "* [Use scripts to perform actions](#use-scripts-to-perform-actions)\n" + + " * [Understand how scripts work](#understand-how-scripts-work)\n" + + " * [Install packages with scripts](#install-packages-with-scripts)\n" + + "* [Import archives](#import-archives)\n" + + "* [Export archives](#export-archives)\n" + + "* [Use a non-git version control system](#use-a-non-git-version-control-system)\n" + + "* [Customize the `diff` command](#customize-the-diff-command)\n" + + "* [Use a merge tool other than vimdiff](#use-a-merge-tool-other-than-vimdiff)\n" + + "* [Migrate from a dotfile manager that uses symlinks](#migrate-from-a-dotfile-manager-that-uses-symlinks)\n" + + "\n" + + "## Use a hosted repo to manage your dotfiles across multiple machines\n" + + "\n" + + "chezmoi relies on your version control system and hosted repo to share changes\n" + + "across multiple machines. You should create a repo on the source code repository\n" + + "of your choice (e.g. [Bitbucket](https://bitbucket.org),\n" + + "[GitHub](https://github.com/), or [GitLab](https://gitlab.com), many people call\n" + + "their repo `dotfiles`) and push the repo in the source directory here. For\n" + + "example:\n" + + "\n" + + " chezmoi cd\n" + + " git remote add origin https://github.com/username/dotfiles.git\n" + + " git push -u origin master\n" + + " exit\n" + + "\n" + + "On another machine you can checkout this repo:\n" + + "\n" + + " chezmoi init https://github.com/username/dotfiles.git\n" + + "\n" + + "You can then see what would be changed:\n" + + "\n" + + " chezmoi diff\n" + + "\n" + + "If you're happy with the changes then apply them:\n" + + "\n" + + " chezmoi apply\n" + + "\n" + + "The above commands can be combined into a single init, checkout, and apply:\n" + + "\n" + + " chezmoi init --apply --verbose https://github.com/username/dotfiles.git\n" + + "\n" + + "## Pull the latest changes from your repo and apply them\n" + + "\n" + + "You can pull the changes from your repo and apply them in a single command:\n" + + "\n" + + " chezmoi update\n" + + "\n" + + "This runs `git pull --rebase` in your source directory and then `chezmoi apply`.\n" + + "\n" + + "## Pull the latest changes from your repo and see what would change, without actually applying the changes\n" + + "\n" + + "Run:\n" + + "\n" + + " chezmoi source pull -- --rebase && chezmoi diff\n" + + "\n" + + "This runs `git pull --rebase` in your source directory and `chezmoi\n" + + "diff` then shows the difference between the target state computed from your\n" + + "source directory and the actual state.\n" + + "\n" + + "If you're happy with the changes, then you can run\n" + + "\n" + + " chezmoi apply\n" + + "\n" + + "to apply them.\n" + + "\n" + + "## Automatically commit and push changes to your repo\n" + + "\n" + + "chezmoi can automatically commit and push changes to your source directory to\n" + + "your repo. This feature is disabled by default. To enable it, add the following\n" + + "to your config file:\n" + + "\n" + + " [sourceVCS]\n" + + " autoCommit = true\n" + + " autoPush = true\n" + + "\n" + + "Whenever a change is made to your source directory, chezmoi will commit the\n" + + "changes with an automatically-generated commit message (if `autoCommit` is true)\n" + + "and push them to your repo (if `autoPush` is true). `autoPush` implies\n" + + "`autoCommit`, i.e. if `autoPush` is true then chezmoi will auto-commit your\n" + + "changes. If you only set `autoCommit` to true then changes will be committed but\n" + + "not pushed.\n" + + "\n" + + "Be careful when using `autoPush`. If your dotfiles repo is public and you\n" + + "accidentally add a secret in plain text, that secret will be pushed to your\n" + + "public repo.\n" + + "\n" + + "## Use templates to manage files that vary from machine to machine\n" + + "\n" + + "The primary goal of chezmoi is to manage configuration files across multiple\n" + + "machines, for example your personal macOS laptop, your work Ubuntu desktop, and\n" + + "your work Linux laptop. You will want to keep much configuration the same across\n" + + "these, but also need machine-specific configurations for email addresses,\n" + + "credentials, etc. chezmoi achieves this functionality by using\n" + + "[`text/template`](https://pkg.go.dev/text/template) for the source state where\n" + + "needed.\n" + + "\n" + + "For example, your home `~/.gitconfig` on your personal machine might look like:\n" + + "\n" + + " [user]\n" + + " email = \"john@home.org\"\n" + + "\n" + + "Whereas at work it might be:\n" + + "\n" + + " [user]\n" + + " email = \"john.smith@company.com\"\n" + + "\n" + + "To handle this, on each machine create a configuration file called\n" + + "`~/.config/chezmoi/chezmoi.toml` defining variables that might vary from machine\n" + + "to machine. For example, for your home machine:\n" + + "\n" + + " [data]\n" + + " email = \"john@home.org\"\n" + + "\n" + + "Note that all variable names will be converted to lowercase. This is due to a\n" + + "feature of a library used by chezmoi.\n" + + "\n" + + "If you intend to store private data (e.g. access tokens) in\n" + + "`~/.config/chezmoi/chezmoi.toml`, make sure it has permissions `0600`.\n" + + "\n" + + "If you prefer, you can use any format supported by\n" + + "[Viper](https://github.com/spf13/viper) for your configuration file. This\n" + + "includes JSON, YAML, and TOML. Variable names must start with a letter and be\n" + + "followed by zero or more letters or digits.\n" + + "\n" + + "Then, add `~/.gitconfig` to chezmoi using the `--autotemplate` flag to turn it\n" + + "into a template and automatically detect variables from the `data` section\n" + + "of your `~/.config/chezmoi/chezmoi.toml` file:\n" + + "\n" + + " chezmoi add --autotemplate ~/.gitconfig\n" + + "\n" + + "You can then open the template (which will be saved in the file\n" + + "`~/.local/share/chezmoi/dot_gitconfig.tmpl`):\n" + + "\n" + + " chezmoi edit ~/.gitconfig\n" + + "\n" + + "The file should look something like:\n" + + "\n" + + " [user]\n" + + " email = \"{{ .email }}\"\n" + + "\n" + + "To disable automatic variable detection, use the `--template` or `-T` option to\n" + + "`chezmoi add` instead of `--autotemplate`.\n" + + "\n" + + "Templates are often used to capture machine-specific differences. For example,\n" + + "in your `~/.local/share/chezmoi/dot_bashrc.tmpl` you might have:\n" + + "\n" + + " # common config\n" + + " export EDITOR=vi\n" + + "\n" + + " # machine-specific configuration\n" + + " {{- if eq .chezmoi.hostname \"work-laptop\" }}\n" + + " # this will only be included in ~/.bashrc on work-laptop\n" + + " {{- end }}\n" + + "\n" + + "For a full list of variables, run:\n" + + "\n" + + " chezmoi data\n" + + "\n" + + "For more advanced usage, you can use the full power of the\n" + + "[`text/template`](https://pkg.go.dev/text/template) language. chezmoi includes\n" + + "all of the text functions from [sprig](http://masterminds.github.io/sprig/) and\n" + + "its own [functions for interacting with password\n" + + "managers](https://github.com/twpayne/chezmoi/blob/master/docs/REFERENCE.md#template-functions).\n" + + "\n" + + "Templates can be executed directly from the command line, without the need to\n" + + "create a file on disk, with the `execute-template` command, for example:\n" + + "\n" + + " chezmoi execute-template '{{ .chezmoi.os }}/{{ .chezmoi.arch }}'\n" + + "\n" + + "This is useful when developing or debugging templates.\n" + + "\n" + + "Some password managers allow you to store complete files. The files can be\n" + + "retrieved with chezmoi's template functions. For example, if you have a file\n" + + "stored in 1Password with the UUID `uuid` then you can retrieve it with the\n" + + "template:\n" + + "\n" + + " {{- onepasswordDocument \"uuid\" -}}\n" + + "\n" + + "The `-`s inside the brackets remove any whitespace before or after the template\n" + + "expression, which is useful if your editor has added any newlines.\n" + + "\n" + + "If, after executing the template, the file contents are empty, the target file\n" + + "will be removed. This can be used to ensure that files are only present on\n" + + "certain machines. If you want an empty file to be created anyway, you will need\n" + + "to give it an `empty_` prefix.\n" + + "\n" + + "For coarser-grained control of files and entire directories are managed on\n" + + "different machines, or to exclude certain files completely, you can create\n" + + "`.chezmoiignore` files in the source directory. These specify a list of patterns\n" + + "that chezmoi should ignore, and are interpreted as templates. An example\n" + + "`.chezmoiignore` file might look like:\n" + + "\n" + + " README.md\n" + + " {{- if ne .chezmoi.hostname \"work-laptop\" }}\n" + + " .work # only manage .work on work-laptop\n" + + " {{- end }}\n" + + "\n" + + "Patterns can be excluded by prefixing them with a `!`, for example:\n" + + "\n" + + " f*\n" + + " !foo\n" + + "\n" + + "will ignore all files beginning with an `f` except `foo`.\n" + + "\n" + + "## Use completely separate config files on different machines\n" + + "\n" + + "chezmoi's template functionality allows you to change a file's contents based on\n" + + "any variable. For example, if you want `~/.bashrc` to be different on Linux and\n" + + "macOS you would create a file in the source state called `dot_bashrc.tmpl`\n" + + "containing:\n" + + "\n" + + "```\n" + + "{{ if eq .chezmoi.os \"darwin\" -}}\n" + + "# macOS .bashrc contents\n" + + "{{ else if eq .chezmoi.os \"linux\" -}}\n" + + "# Linux .bashrc contents\n" + + "{{ end -}}\n" + + "```\n" + + "\n" + + "However, if the differences between the two versions are so large that you'd\n" + + "prefer to use completely separate files in the source state, you can achieve\n" + + "this using a symbolic link template. Create the following files:\n" + + "\n" + + "`symlink_dot_bashrc.tmpl`:\n" + + "\n" + + "```\n" + + ".bashrc_{{ .chezmoi.os }}\n" + + "```\n" + + "\n" + + "`dot_bashrc_darwin`:\n" + + "\n" + + "```\n" + + "# macOS .bashrc contents\n" + + "```\n" + + "\n" + + "`dot_bashrc_linux`:\n" + + "\n" + + "```\n" + + "# Linux .bashrc contents\n" + + "```\n" + + "\n" + + "`.chezmoiignore`\n" + + "\n" + + "```\n" + + "{{ if ne .chezmoi.os \"darwin\" }}\n" + + ".bashrc_darwin\n" + + "{{ end }}\n" + + "{{ if ne .chezmoi.os \"linux\" }}\n" + + ".bashrc_linux\n" + + "{{ end }}\n" + + "```\n" + + "\n" + + "This will make `~/.bashrc` will be a symlink to `.bashrc_darwin` on `darwin` and\n" + + "to `.bashrc_linux`. The `.chezmoiignore` configuration ensures that only the\n" + + "OS-specific `.bashrc_os` file will be installed on each OS.\n" + + "\n" + + "## Create a config file on a new machine automatically\n" + + "\n" + + "`chezmoi init` can also create a config file automatically, if one does not\n" + + "already exist. If your repo contains a file called `.chezmoi..tmpl`\n" + + "where *format* is one of the supported config file formats (e.g. `json`, `toml`,\n" + + "or `yaml`) then `chezmoi init` will execute that template to generate your\n" + + "initial config file.\n" + + "\n" + + "Specifically, if you have `.chezmoi.toml.tmpl` that looks like this:\n" + + "\n" + + " {{- $email := promptString \"email\" -}}\n" + + " [data]\n" + + " email = \"{{ $email }}\"\n" + + "\n" + + "Then `chezmoi init` will create an initial `chezmoi.toml` using this template.\n" + + "`promptString` is a special function that prompts the user (you) for a value.\n" + + "\n" + + "To test this template, use `chezmoi execute-template` with the `--init` and\n" + + "`--promptString` flags, for example:\n" + + "\n" + + " chezmoi execute-template --init --promptString email=john@home.org < ~/.local/share/chezmoi/.chezmoi.toml.tmpl\n" + + "\n" + + "## Have chezmoi create a directory, but ignore its contents\n" + + "\n" + + "If you want chezmoi to create a directory, but ignore its contents, say `~/src`,\n" + + "first run:\n" + + "\n" + + " mkdir -p $(chezmoi source-path)/src\n" + + "\n" + + "This creates the directory in the source state, which means that chezmoi will\n" + + "create it (if it does not already exist) when you run `chezmoi apply`.\n" + + "\n" + + "However, as this is an empty directory it will be ignored by git. So, create a\n" + + "file in the directory in the source state that will be seen by git (so git does\n" + + "not ignore the directory) but ignored by chezmoi (so chezmoi does not include it\n" + + "in the target state):\n" + + "\n" + + " touch $(chezmoi source-path)/src/.keep\n" + + "\n" + + "chezmoi automatically creates `.keep` files when you add an empty directory with\n" + + "`chezmoi add`.\n" + + "\n" + + "## Ensure that a target is removed\n" + + "\n" + + "Create a file called `.chezmoiremove` in the source directory containing a list\n" + + "of patterns of files to remove. When you run\n" + + "\n" + + " chezmoi apply --remove\n" + + "\n" + + "chezmoi will remove anything in the target directory that matches the pattern.\n" + + "As this command is potentially dangerous, you should run chezmoi in verbose,\n" + + "dry-run mode beforehand to see what would be removed:\n" + + "\n" + + " chezmoi apply --remove --dry-run --verbose\n" + + "\n" + + "`.chezmoiremove` is interpreted as a template, so you can remove different files\n" + + "on different machines. Negative matches (patterns prefixed with a `!`) or\n" + + "targets listed in `.chezmoiignore` will never be removed.\n" + + "\n" + + "## Include a subdirectory from another repository, like Oh My Zsh\n" + + "\n" + + "To include a subdirectory from another repository, e.g. [Oh My\n" + + "Zsh](https://github.com/robbyrussell/oh-my-zsh), you cannot use git submodules\n" + + "because chezmoi uses its own format for the source state and Oh My Zsh is not\n" + + "distributed in this format. Instead, you can use the `import` command to import\n" + + "a snapshot from a tarball:\n" + + "\n" + + " curl -s -L -o oh-my-zsh-master.tar.gz https://github.com/robbyrussell/oh-my-zsh/archive/master.tar.gz\n" + + " chezmoi import --strip-components 1 --destination ${HOME}/.oh-my-zsh oh-my-zsh-master.tar.gz\n" + + "\n" + + "Add `oh-my-zsh-master.tar.gz` to `.chezmoiignore` if you run these commands in\n" + + "your source directory so that chezmoi doesn't try to copy the tarball anywhere.\n" + + "\n" + + "Disable Oh My Zsh auto-updates by setting `DISABLE_AUTO_UPDATE=\"true\"` in\n" + + "`~/.zshrc`. Auto updates will cause the `~/.oh-my-zsh` directory to drift out of\n" + + "sync with chezmoi's source state. To update Oh My Zsh, re-run the `curl` and\n" + + "`chezmoi import` commands above.\n" + + "\n" + + "## Handle configuration files which are externally modified\n" + + "\n" + + "Some programs modify their configuration files. When you next run `chezmoi\n" + + "apply`, any modifications made by the program will be lost.\n" + + "\n" + + "You can track changes to these files by replacing with a symlink back to a file\n" + + "in your source directory, which is under version control. Here is a worked\n" + + "example for VSCode's `settings.json` on Linux:\n" + + "\n" + + "Copy the configuration file to your source directory:\n" + + "\n" + + " cp ~/.config/Code/User/settings.json $(chezmoi source-path)\n" + + "\n" + + "Tell chezmoi to ignore this file:\n" + + "\n" + + " echo settings.json >> $(chezmoi source-path)/.chezmoiignore\n" + + "\n" + + "Tell chezmoi that `~/.config/Code/User/settings.json` should be a symlink to the\n" + + "file in your source directory:\n" + + "\n" + + " mkdir -p $(chezmoi source-path)/private_dot_config/private_Code/User\n" + + " echo -n \"{{ .chezmoi.sourceDir }}/settings.json\" > $(chezmoi source-path)/private_dot_config/private_Code/User/symlink_settings.json.tmpl\n" + + "\n" + + "The prefix `private_` is used because the `~/.config` and `~/.config/Code`\n" + + "directories are private by default.\n" + + "\n" + + "Apply the changes:\n" + + "\n" + + " chezmoi apply -v\n" + + "\n" + + "Now, when the program modifies its configuration file it will modify the file in\n" + + "the source state instead.\n" + + "\n" + + "## Keep data private\n" + + "\n" + + "chezmoi automatically detects when files and directories are private when adding\n" + + "them by inspecting their permissions. Private files and directories are stored\n" + + "in `~/.local/share/chezmoi` as regular, public files with permissions `0644` and\n" + + "the name prefix `private_`. For example:\n" + + "\n" + + " chezmoi add ~/.netrc\n" + + "\n" + + "will create `~/.local/share/chezmoi/private_dot_netrc` (assuming `~/.netrc` is\n" + + "not world- or group- readable, as it should be). This file is still private\n" + + "because `~/.local/share/chezmoi` is not group- or world- readable or executable.\n" + + "chezmoi checks that the permissions of `~/.local/share/chezmoi` are `0700` on\n" + + "every run and will print a warning if they are not.\n" + + "\n" + + "It is common that you need to store access tokens in config files, e.g. a\n" + + "[GitHub access\n" + + "token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/).\n" + + "There are several ways to keep these tokens secure, and to prevent them leaving\n" + + "your machine.\n" + + "\n" + + "### Use Bitwarden to keep your secrets\n" + + "\n" + + "chezmoi includes support for [Bitwarden](https://bitwarden.com/) using the\n" + + "[Bitwarden CLI](https://github.com/bitwarden/cli) to expose data as a template\n" + + "function.\n" + + "\n" + + "Log in to Bitwarden using:\n" + + "\n" + + " bw login \n" + + "\n" + + "Unlock your Bitwarden vault:\n" + + "\n" + + " bw unlock\n" + + "\n" + + "Set the `BW_SESSION` environment variable, as instructed.\n" + + "\n" + + "The structured data from `bw get` is available as the `bitwarden` template\n" + + "function in your config files, for example:\n" + + "\n" + + " username = {{ (bitwarden \"item\" \"example.com\").login.username }}\n" + + " password = {{ (bitwarden \"item\" \"example.com\").login.password }}\n" + + "\n" + + "### Use gopass to keep your secrets\n" + + "\n" + + "chezmoi includes support for [gopass](https://gopass.pw/) using the gopass CLI.\n" + + "\n" + + "The first line of the output of `gopass show ` is available as the\n" + + "`gopass` template function, for example:\n" + + "\n" + + " {{ gopass \"\" }}\n" + + "\n" + + "### Use gpg to keep your secrets\n" + + "\n" + + "chezmoi supports encrypting files with [gpg](https://www.gnupg.org/). Encrypted\n" + + "files are stored in the source state and automatically be decrypted when\n" + + "generating the target state or printing a file's contents with `chezmoi cat`.\n" + + "`chezmoi edit` will transparently decrypt the file before editing and re-encrypt\n" + + "it afterwards.\n" + + "\n" + + "#### Asymmetric (private/public-key) encryption\n" + + "\n" + + "Specify the encryption key to use in your configuration file (`chezmoi.toml`)\n" + + "with the `gpg.recipient` key:\n" + + "\n" + + " [gpg]\n" + + " recipient = \"...\"\n" + + "\n" + + "Add files to be encrypted with the `--encrypt` flag, for example:\n" + + "\n" + + " chezmoi add --encrypt ~/.ssh/id_rsa\n" + + "\n" + + "chezmoi will encrypt the file with:\n" + + "\n" + + " gpg --armor --recipient ${gpg.recipient} --encrypt\n" + + "\n" + + "and store the encrypted file in the source state. The file will automatically be\n" + + "decrypted when generating the target state.\n" + + "\n" + + "#### Symmetric encryption\n" + + "\n" + + "Specify symmetric encryption in your configuration file:\n" + + "\n" + + " [gpg]\n" + + " symmetric = true\n" + + "\n" + + "Add files to be encrypted with the `--encrypt` flag, for example:\n" + + "\n" + + " chezmoi add --encrypt ~/.ssh/id_rsa\n" + + "\n" + + "chezmoi will encrypt the file with:\n" + + "\n" + + " gpg --armor --symmetric\n" + + "\n" + + "### Use KeePassXC to keep your secrets\n" + + "\n" + + "chezmoi includes support for [KeePassXC](https://keepassxc.org) using the\n" + + "KeePassXC CLI (`keepassxc-cli`) to expose data as a template function.\n" + + "\n" + + "Provide the path to your KeePassXC database in your configuration file:\n" + + "\n" + + " [keepassxc]\n" + + " database = \"/home/user/Passwords.kdbx\"\n" + + "\n" + + "The structured data from `keepassxc-cli show $database` is available as the\n" + + "`keepassxc` template function in your config files, for example:\n" + + "\n" + + " username = {{ (keepassxc \"example.com\").UserName }}\n" + + " password = {{ (keepassxc \"example.com\").Password }}\n" + + "\n" + + "Additional attributes are available through the `keepassxcAttribute` function.\n" + + "For example, if you have an entry called `SSH Key` with an additional attribute\n" + + "called `private-key`, its value is available as:\n" + + "\n" + + " {{ keepassxcAttribute \"SSH Key\" \"private-key\" }}\n" + + "\n" + + "### Use a keyring to keep your secrets\n" + + "\n" + + "chezmoi includes support for Keychain (on macOS), GNOME Keyring (on Linux), and\n" + + "Windows Credentials Manager (on Windows) via the\n" + + "[`zalando/go-keyring`](https://github.com/zalando/go-keyring) library.\n" + + "\n" + + "Set passwords with:\n" + + "\n" + + " $ chezmoi keyring set --service= --user=\n" + + " Password: xxxxxxxx\n" + + "\n" + + "The password can then be used in templates using the `keyring` function which\n" + + "takes the service and user as arguments.\n" + + "\n" + + "For example, save a GitHub access token in keyring with:\n" + + "\n" + + " $ chezmoi keyring set --service=github --user=\n" + + " Password: xxxxxxxx\n" + + "\n" + + "and then include it in your `~/.gitconfig` file with:\n" + + "\n" + + " [github]\n" + + " user = \"{{ .github.user }}\"\n" + + " token = \"{{ keyring \"github\" .github.user }}\"\n" + + "\n" + + "You can query the keyring from the command line:\n" + + "\n" + + " chezmoi keyring get --service=github --user=\n" + + "\n" + + "### Use LastPass to keep your secrets\n" + + "\n" + + "chezmoi includes support for [LastPass](https://lastpass.com) using the\n" + + "[LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html) to expose\n" + + "data as a template function.\n" + + "\n" + + "Log in to LastPass using:\n" + + "\n" + + " lpass login \n" + + "\n" + + "Check that `lpass` is working correctly by showing password data:\n" + + "\n" + + " lpass show --json \n" + + "\n" + + "where `` is a [LastPass Entry\n" + + "Specification](https://lastpass.github.io/lastpass-cli/lpass.1.html#_entry_specification).\n" + + "\n" + + "The structured data from `lpass show --json id` is available as the `lastpass`\n" + + "template function. The value will be an array of objects. You can use the\n" + + "`index` function and `.Field` syntax of the `text/template` language to extract\n" + + "the field you want. For example, to extract the `password` field from first the\n" + + "\"GitHub\" entry, use:\n" + + "\n" + + " githubPassword = \"{{ (index (lastpass \"GitHub\") 0).password }}\"\n" + + "\n" + + "chezmoi automatically parses the `note` value of the Lastpass entry as\n" + + "colon-separated key-value pairs, so, for example, you can extract a private SSH\n" + + "key like this:\n" + + "\n" + + " {{ (index (lastpass \"SSH\") 0).note.privateKey }}\n" + + "\n" + + "Keys in the `note` section written as `CamelCase Words` are converted to\n" + + "`camelCaseWords`.\n" + + "\n" + + "If the `note` value does not contain colon-separated key-value pairs, then you\n" + + "can use `lastpassRaw` to get its raw value, for example:\n" + + "\n" + + " {{ (index (lastpassRaw \"SSH Private Key\") 0).note }}\n" + + "\n" + + "### Use 1Password to keep your secrets\n" + + "\n" + + "chezmoi includes support for [1Password](https://1password.com/) using the\n" + + "[1Password CLI](https://support.1password.com/command-line-getting-started/) to\n" + + "expose data as a template function.\n" + + "\n" + + "Log in and get a session using:\n" + + "\n" + + " eval $(op signin .1password.com )\n" + + "\n" + + "The output of `op get item ` is available as the `onepassword` template\n" + + "function. chezmoi parses the JSON output and returns it as structured data. For\n" + + "example, if the output of `op get item \"\"` is:\n" + + "\n" + + " {\n" + + " \"uuid\": \"\",\n" + + " \"details\": {\n" + + " \"password\": \"xxx\"\n" + + " }\n" + + " }\n" + + "\n" + + "Then you can access `details.password` with the syntax:\n" + + "\n" + + " {{ (onepassword \"\").details.password }}\n" + + "\n" + + "Documents can be retrieved with:\n" + + "\n" + + " {{- onepasswordDocument \"uuid\" -}}\n" + + "\n" + + "Note the extra `-` after the opening `{{` and before the closing `}}`. This\n" + + "instructs the template language to remove and whitespace before and after the\n" + + "substitution. This removes any trailing newline added by your editor when saving\n" + + "the template.\n" + + "\n" + + "### Use pass to keep your secrets\n" + + "\n" + + "chezmoi includes support for [pass](https://www.passwordstore.org/) using the\n" + + "pass CLI.\n" + + "\n" + + "The first line of the output of `pass show ` is available as the\n" + + "`pass` template function, for example:\n" + + "\n" + + " {{ pass \"\" }}\n" + + "\n" + + "### Use Vault to keep your secrets\n" + + "\n" + + "chezmoi includes support for [Vault](https://www.vaultproject.io/) using the\n" + + "[Vault CLI](https://www.vaultproject.io/docs/commands/) to expose data as a\n" + + "template function.\n" + + "\n" + + "The vault CLI needs to be correctly configured on your machine, e.g. the\n" + + "`VAULT_ADDR` and `VAULT_TOKEN` environment variables must be set correctly.\n" + + "Verify that this is the case by running:\n" + + "\n" + + " vault kv get -format=json \n" + + "\n" + + "The structured data from `vault kv get -format=json` is available as the `vault`\n" + + "template function. You can use the `.Field` syntax of the `text/template`\n" + + "language to extract the data you want. For example:\n" + + "\n" + + " {{ (vault \"\").data.data.password }}\n" + + "\n" + + "### Use a generic tool to keep your secrets\n" + + "\n" + + "You can use any command line tool that outputs secrets either as a string or in\n" + + "JSON format. Choose the binary by setting `genericSecret.command` in your\n" + + "configuration file. You can then invoke this command with the `secret` and\n" + + "`secretJSON` template functions which return the raw output and JSON-decoded\n" + + "output respectively. All of the above secret managers can be supported in this\n" + + "way:\n" + + "\n" + + "| Secret Manager | `genericSecret.command` | Template skeleton |\n" + + "| --------------- | ----------------------- | ------------------------------------------------- |\n" + + "| 1Password | `op` | `{{ secretJSON \"get\" \"item\" }}` |\n" + + "| Bitwarden | `bw` | `{{ secretJSON \"get\" }}` |\n" + + "| Hashicorp Vault | `vault` | `{{ secretJSON \"kv\" \"get\" \"-format=json\" }}` |\n" + + "| LastPass | `lpass` | `{{ secretJSON \"show\" \"--json\" }}` |\n" + + "| KeePassXC | `keepassxc-cli` | Not possible (interactive command only) |\n" + + "| pass | `pass` | `{{ secret \"show\" }}` |\n" + + "\n" + + "### Use templates variables to keep your secrets\n" + + "\n" + + "Typically, `~/.config/chezmoi/chezmoi.toml` is not checked in to version control\n" + + "and has permissions 0600. You can store tokens as template values in the `data`\n" + + "section. For example, if your `~/.config/chezmoi/chezmoi.toml` contains:\n" + + "\n" + + " [data]\n" + + " [data.github]\n" + + " user = \"\"\n" + + " token = \"\"\n" + + "\n" + + "Your `~/.local/share/chezmoi/private_dot_gitconfig.tmpl` can then contain:\n" + + "\n" + + " {{- if (index . \"github\") }}\n" + + " [github]\n" + + " user = \"{{ .github.user }}\"\n" + + " token = \"{{ .github.token }}\"\n" + + " {{- end }}\n" + + "\n" + + "Any config files containing tokens in plain text should be private (permissions\n" + + "`0600`).\n" + + "\n" + + "## Use scripts to perform actions\n" + + "\n" + + "### Understand how scripts work\n" + + "\n" + + "chezmoi supports scripts, which are executed when you run `chezmoi apply`. The\n" + + "scripts can either run every time you run `chezmoi apply`, or only when their\n" + + "contents have changed.\n" + + "\n" + + "In verbose mode, the script's contents will be printed before executing it. In\n" + + "dry-run mode, the script is not executed.\n" + + "\n" + + "Scripts are any file in the source directory with the prefix `run_`, and are\n" + + "executed in alphabetical order. Scripts that should only be run when their\n" + + "contents change have the prefix `run_once_`.\n" + + "\n" + + "Scripts break chezmoi's declarative approach, and as such should be used\n" + + "sparingly. Any script should be idempotent, even `run_once_` scripts.\n" + + "\n" + + "Scripts must be created manually in the source directory, typically by running\n" + + "`chezmoi cd` and then creating a file with a `run_` prefix. Scripts are executed\n" + + "directly using `exec` and must include a shebang line or be executable binaries.\n" + + "There is no need to set the executable bit on the script.\n" + + "\n" + + "Scripts with the suffix `.tmpl` are treated as templates, with the usual\n" + + "template variables available. If, after executing the template, the result is\n" + + "only whitespace or an empty string, then the script is not executed. This is\n" + + "useful for disabling scripts.\n" + + "\n" + + "### Install packages with scripts\n" + + "\n" + + "Change to the source directory and create a file called\n" + + "`run_once_install-packages.sh`:\n" + + "\n" + + " chezmoi cd\n" + + " $EDITOR run_once_install-packages.sh\n" + + "\n" + + "In this file create your package installation script, e.g.\n" + + "\n" + + " #!/bin/sh\n" + + " sudo apt install ripgrep\n" + + "\n" + + "The next time you run `chezmoi apply` or `chezmoi update` this script will be\n" + + "run. As it has the `run_once_` prefix, it will not be run again unless its\n" + + "contents change, for example if you add more packages to be installed.\n" + + "\n" + + "This script can also be a template. For example, if you create\n" + + "`run_once_install-packages.sh.tmpl` with the contents:\n" + + "\n" + + " {{ if eq .chezmoi.os \"linux\" -}}\n" + + " #!/bin/sh\n" + + " sudo apt install ripgrep\n" + + " {{ else if eq .chezmoi.os \"darwin\" -}}\n" + + " #!/bin/sh\n" + + " brew install ripgrep\n" + + " {{ end -}}\n" + + "\n" + + "This will install `ripgrep` on both Debian/Ubuntu Linux systems and macOS.\n" + + "\n" + + "## Import archives\n" + + "\n" + + "It is occasionally useful to import entire archives of configuration into your\n" + + "source state. The `import` command does this. For example, to import the latest\n" + + "version\n" + + "[`github.com/robbyrussell/oh-my-zsh`](https://github.com/robbyrussell/oh-my-zsh)\n" + + "to `~/.oh-my-zsh` run:\n" + + "\n" + + " curl -s -L -o oh-my-zsh-master.tar.gz https://github.com/robbyrussell/oh-my-zsh/archive/master.tar.gz\n" + + " chezmoi import --strip-components 1 --destination ~/.oh-my-zsh oh-my-zsh-master.tar.gz\n" + + "\n" + + "Note that this only updates the source state. You will need to run\n" + + "\n" + + " chezmoi apply\n" + + "\n" + + "to update your destination directory.\n" + + "\n" + + "## Export archives\n" + + "\n" + + "chezmoi can create an archive containing the target state. This can be useful\n" + + "for generating target state on a different machine or for simply inspecting the\n" + + "target state. A particularly useful command is:\n" + + "\n" + + " chezmoi archive | tar tvf -\n" + + "\n" + + "which lists all the targets in the target state.\n" + + "\n" + + "## Use a non-git version control system\n" + + "\n" + + "By default, chezmoi uses git, but you can use any version control system of your\n" + + "choice. In your config file, specify the command to use. For example, to use\n" + + "Mercurial specify:\n" + + "\n" + + " [sourceVCS]\n" + + " command = \"hg\"\n" + + "\n" + + "The source VCS command is used in the chezmoi commands `init`, `source`, and\n" + + "`update`, and support for VCSes other than git is limited but easy to add. If\n" + + "you'd like to see your VCS better supported, please [open an issue on\n" + + "GitHub](https://github.com/twpayne/chezmoi/issues/new/choose).\n" + + "\n" + + "## Customize the `diff` command\n" + + "\n" + + "By default, chezmoi uses a built-in diff. You can change the format, and/or pipe\n" + + "the output into a pager of your choice. For example, to use\n" + + "[`diff-so-fancy`](https://github.com/so-fancy/diff-so-fancy) specify:\n" + + "\n" + + " [diff]\n" + + " format = \"git\"\n" + + " pager = \"diff-so-fancy\"\n" + + "\n" + + "The format can also be set with the `--format` option to the `diff` command, and\n" + + "the pager can be disabled using `--no-pager`.\n" + + "\n" + + "## Use a merge tool other than vimdiff\n" + + "\n" + + "By default, chezmoi uses vimdiff, but you can use any merge tool of your choice.\n" + + "In your config file, specify the command and args to use. For example, to use\n" + + "neovim's diff mode specify:\n" + + "\n" + + " [merge]\n" + + " command = \"nvim\"\n" + + " args = \"-d\"\n" + + "\n" + + "## Migrate from a dotfile manager that uses symlinks\n" + + "\n" + + "Many dotfile managers replace dotfiles with symbolic links to files in a common\n" + + "directory. If you `chezmoi add` such a symlink, chezmoi will add the symlink,\n" + + "not the file. To assist with migrating from symlink-based systems, use the\n" + + "`--follow` / `-f` option to `chezmoi add`, for example:\n" + + "\n" + + " chezmoi add --follow ~/.bashrc\n" + + "\n" + + "This will tell `chezmoi add` that the target state of `~/.bashrc` is the target\n" + + "of the `~/.bashrc` symlink, rather than the symlink itself. When you run\n" + + "`chezmoi apply`, chezmoi will replace the `~/.bashrc` symlink with the file\n" + + "contents.\n" + + "\n") + assets["docs/INSTALL.md"] = []byte("" + + "# chezmoi Install Guide\n" + + "\n" + + "\n" + + "* [One-line binary install](#one-line-binary-install)\n" + + "* [One-line package install](#one-line-package-install)\n" + + "* [Pre-built Linux packages](#pre-built-linux-packages)\n" + + "* [Pre-built binaries](#pre-built-binaries)\n" + + "* [All pre-built Linux packages and binaries](#all-pre-built-linux-packages-and-binaries)\n" + + "* [From source](#from-source)\n" + + "* [Upgrading](#upgrading)\n" + + "\n" + + "## One-line binary install\n" + + "\n" + + "Install the correct binary for your operating system and architecture in `./bin`\n" + + "with a single command.\n" + + "\n" + + " curl -sfL https://git.io/chezmoi | sh\n" + + "\n" + + "## One-line package install\n" + + "\n" + + "Install chezmoi with a single command.\n" + + "\n" + + "| OS | Method | Command |\n" + + "| ------------ | ---------- | ------------------------------------------------------------------------------------------- |\n" + + "| Linux | snap | `snap install chezmoi --classic` |\n" + + "| Linux | Linuxbrew | `brew install chezmoi` |\n" + + "| Alpine Linux | apk | `apk add chezmoi` |\n" + + "| Arch Linux | pacman | `pacman -S chezmoi` |\n" + + "| NixOS Linux | nix-env | `nix-env -i chezmoi` |\n" + + "| macOS | Homebrew | `brew install chezmoi` |\n" + + "| Windows | Scoop | `scoop bucket add twpayne https://github.com/twpayne/scoop-bucket && scoop install chezmoi` |\n" + + "\n" + + "## Pre-built Linux packages\n" + + "\n" + + "Download a package for your operating system and architecture and install it\n" + + "with your package manager.\n" + + "\n" + + "| Distribution | Architectures | Package |\n" + + "| ------------ | --------------------------------------------------------- | ------------------------------------------------------------------------- |\n" + + "| Debian | `amd64`, `arm64`, `armel`, `i386`, `ppc64`, `ppc64le` | [`deb`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| RedHat | `aarch64`, `armhfp`, `i686`, `ppc64`, `ppc64le`, `x86_64` | [`rpm`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| OpenSUSE | `aarch64`, `armhfp`, `i686`, `ppc64`, `ppc64le`, `x86_64` | [`rpm`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| Ubuntu | `amd64`, `arm64`, `armel`, `i386`, `ppc64`, `ppc64le` | [`deb`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "\n" + + "## Pre-built binaries\n" + + "\n" + + "Download an archive for your operating system containing a pre-built binary,\n" + + "documentation, and shell completions.\n" + + "\n" + + "| OS | Architectures | Archive |\n" + + "| ---------- | --------------------------------------------------- | -------------------------------------------------------------- |\n" + + "| FreeBSD | `amd64`, `arm`, `arm64`, `i386` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| Linux | `amd64`, `arm`, `arm64`, `i386`, `ppc64`, `ppc64le` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| macOS | `amd64` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| OpenBSD | `amd64`, `arm`, `arm64`, `i386` | [`tar.gz`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "| Windows | `amd64`, `i386` | [`zip`](https://github.com/twpayne/chezmoi/releases/latest) |\n" + + "\n" + + "## All pre-built Linux packages and binaries\n" + + "\n" + + "All pre-built binaries and packages can be found on the [chezmoi GitHub releases\n" + + "page](https://github.com/twpayne/chezmoi/releases/latest).\n" + + "\n" + + "## From source\n" + + "\n" + + "Download, build, and install chezmoi for your system:\n" + + "\n" + + " cd $(mktemp -d)\n" + + " git clone --depth=1 https://github.com/twpayne/chezmoi.git\n" + + " cd chezmoi\n" + + " go install\n" + + "\n" + + "Building chezmoi requires Go 1.13 or later.\n" + + "\n" + + "## Upgrading\n" + + "\n" + + "If you have installed a pre-built binary of chezmoi, you can upgrade it to the\n" + + "latest release with:\n" + + "\n" + + " chezmoi upgrade\n" + + "\n" + + "This will re-use whichever mechanism you used to install chezmoi to install the\n" + + "latest release.\n" + + "\n") + assets["docs/MEDIA.md"] = []byte("" + + "# chezmoi in the media\n" + + "\n" + + "\n" + + "\n" + + "| Date | Version | Format | Link |\n" + + "| ---------- | ------- | ------------ | ------------------------------------------------------------------------------------------------------------------------- |\n" + + "| 2020-04-16 | 1.17.19 | Text (FR) | [Chezmoi, visite guidée](https://blog.wescale.fr/2020/04/16/chezmoi-visite-guidee/) |\n" + + "| 2020-04-03 | 1.7.17 | Text | [Fedora Magazine: Take back your dotfiles with Chezmoi](https://fedoramagazine.org/take-back-your-dotfiles-with-chezmoi/) |\n" + + "| 2020-03-12 | 1.7.16 | Video | [Managing Dotfiles with ChezMoi](https://www.youtube.com/watch?v=HXx6ugA98Qo) |\n" + + "| 2019-11-20 | 1.7.2 | Audio/video | [FLOSS weekly episode 556: chezmoi](https://twit.tv/shows/floss-weekly/episodes/556) |\n" + + "| 2019-01-10 | 0.0.11 | Text | [Linux Fu: The kitchen sync](https://hackaday.com/2019/01/10/linux-fu-the-kitchen-sync/) |\n" + + "\n" + + "To add your article to this page please either [open an\n" + + "issue](https://github.com/twpayne/chezmoi/issues/new/choose) or submit a pull\n" + + "request that modifies this file\n" + + "([`docs/MEDIA.md`](https://github.com/twpayne/chezmoi/blob/master/docs/MEDIA.md)).\n") + assets["docs/QUICKSTART.md"] = []byte("" + + "# chezmoi Quick Start Guide\n" + + "\n" + + "\n" + + "* [Concepts](#concepts)\n" + + "* [Start using chezmoi on your current machine](#start-using-chezmoi-on-your-current-machine)\n" + + "* [Using chezmoi across multiple machines](#using-chezmoi-across-multiple-machines)\n" + + "* [Next steps](#next-steps)\n" + + "\n" + + "## Concepts\n" + + "\n" + + "chezmoi stores the desired state of your dotfiles in the directory\n" + + "`~/.local/share/chezmoi`. When you run `chezmoi apply`, chezmoi calculates the\n" + + "desired contents and permissions for each dotfile and then makes any changes\n" + + "necessary so that your dotfiles match that state.\n" + + "\n" + + "## Start using chezmoi on your current machine\n" + + "\n" + + "Initialize chezmoi:\n" + + "\n" + + " chezmoi init\n" + + "\n" + + "This will create a new git repository in `~/.local/share/chezmoi` with\n" + + "permissions `0700` where chezmoi will store the source state. chezmoi only\n" + + "modifies files in the working copy. It is your responsibility to commit changes.\n" + + "\n" + + "Manage an existing file with chezmoi:\n" + + "\n" + + " chezmoi add ~/.bashrc\n" + + "\n" + + "This will copy `~/.bashrc` to `~/.local/share/chezmoi/dot_bashrc`.\n" + + "\n" + + "Edit the source state:\n" + + "\n" + + " chezmoi edit ~/.bashrc\n" + + "\n" + + "This will open `~/.local/share/chezmoi/dot_bashrc` in your `$EDITOR`. Make some\n" + + "changes and save them.\n" + + "\n" + + "See what changes chezmoi would make:\n" + + "\n" + + " chezmoi diff\n" + + "\n" + + "Apply the changes:\n" + + "\n" + + " chezmoi -v apply\n" + + "\n" + + "All chezmoi commands accept the `-v` (verbose) flag to print out exactly what\n" + + "changes they will make to the file system, and the `-n` (dry run) flag to not\n" + + "make any actual changes. The combination `-n` `-v` is very useful if you want to\n" + + "see exactly what changes would be made.\n" + + "\n" + + "Finally, open a shell in the source directory, commit your changes, and return\n" + + "to where you were:\n" + + "\n" + + " chezmoi cd\n" + + " git add dot_bashrc\n" + + " git commit -m \"Add .bashrc\"\n" + + " exit\n" + + "\n" + + "## Using chezmoi across multiple machines\n" + + "\n" + + "Clone the git repo in `~/.local/share/chezmoi` to a hosted Git service, e.g.\n" + + "[GitHub](https://github.com), [GitLab](https://gitlab.com), or\n" + + "[BitBucket](https://bitbucket.org). Many people call their dotfiles repo\n" + + "`dotfiles`. You can then setup chezmoi on a second machine:\n" + + "\n" + + " chezmoi init https://github.com/username/dotfiles.git\n" + + "\n" + + "This will check out the repo and any submodules and optionally create a chezmoi\n" + + "config file for you. It won't make any changes to your home directory until you\n" + + "run:\n" + + "\n" + + " chezmoi apply\n" + + "\n" + + "On any machine, you can pull and apply the latest changes from your repo with:\n" + + "\n" + + " chezmoi update\n" + + "\n" + + "## Next steps\n" + + "\n" + + "For a full list of commands run:\n" + + "\n" + + " chezmoi help\n" + + "\n" + + "chezmoi has much more functionality. Read the [how-to\n" + + "guide](https://github.com/twpayne/chezmoi/blob/master/docs/HOWTO.md) to explore.\n" + + "\n") + assets["docs/REFERENCE.md"] = []byte("" + + "# chezmoi Reference Manual\n" + + "\n" + + "Manage your dotfiles securely across multiple machines.\n" + + "\n" + + "\n" + + "* [Concepts](#concepts)\n" + + "* [Global command line flags](#global-command-line-flags)\n" + + " * [`--color` *value*](#--color-value)\n" + + " * [`-c`, `--config` *filename*](#-c---config-filename)\n" + + " * [`--debug`](#--debug)\n" + + " * [`-D`, `--destination` *directory*](#-d---destination-directory)\n" + + " * [`-f`, `--follow`](#-f---follow)\n" + + " * [`-n`, `--dry-run`](#-n---dry-run)\n" + + " * [`-h`, `--help`](#-h---help)\n" + + " * [`-r`. `--remove`](#-r---remove)\n" + + " * [`-S`, `--source` *directory*](#-s---source-directory)\n" + + " * [`-v`, `--verbose`](#-v---verbose)\n" + + " * [`--version`](#--version)\n" + + "* [Configuration file](#configuration-file)\n" + + " * [Configuration variables](#configuration-variables)\n" + + "* [Source state attributes](#source-state-attributes)\n" + + "* [Special files and directories](#special-files-and-directories)\n" + + " * [`.chezmoi..tmpl`](#chezmoiformattmpl)\n" + + " * [`.chezmoiignore`](#chezmoiignore)\n" + + " * [`.chezmoiremove`](#chezmoiremove)\n" + + " * [`.chezmoitemplates`](#chezmoitemplates)\n" + + " * [`.chezmoiversion`](#chezmoiversion)\n" + + "* [Commands](#commands)\n" + + " * [`add` *targets*](#add-targets)\n" + + " * [`apply` [*targets*]](#apply-targets)\n" + + " * [`archive`](#archive)\n" + + " * [`cat` targets](#cat-targets)\n" + + " * [`cd`](#cd)\n" + + " * [`chattr` *attributes* *targets*](#chattr-attributes-targets)\n" + + " * [`completion` *shell*](#completion-shell)\n" + + " * [`data`](#data)\n" + + " * [`diff` [*targets*]](#diff-targets)\n" + + " * [`docs` [*regexp*]](#docs-regexp)\n" + + " * [`doctor`](#doctor)\n" + + " * [`dump` [*targets*]](#dump-targets)\n" + + " * [`edit` [*targets*]](#edit-targets)\n" + + " * [`edit-config`](#edit-config)\n" + + " * [`execute-template` [*templates*]](#execute-template-templates)\n" + + " * [`forget` *targets*](#forget-targets)\n" + + " * [`git` [*arguments*]](#git-arguments)\n" + + " * [`help` *command*](#help-command)\n" + + " * [`hg` [*arguments*]](#hg-arguments)\n" + + " * [`init` [*repo*]](#init-repo)\n" + + " * [`import` *filename*](#import-filename)\n" + + " * [`manage` *targets*](#manage-targets)\n" + + " * [`managed`](#managed)\n" + + " * [`merge` *targets*](#merge-targets)\n" + + " * [`purge`](#purge)\n" + + " * [`remove` *targets*](#remove-targets)\n" + + " * [`rm` *targets*](#rm-targets)\n" + + " * [`secret`](#secret)\n" + + " * [`source` [*args*]](#source-args)\n" + + " * [`source-path` [*targets*]](#source-path-targets)\n" + + " * [`unmanage` *targets*](#unmanage-targets)\n" + + " * [`unmanaged`](#unmanaged)\n" + + " * [`update`](#update)\n" + + " * [`upgrade`](#upgrade)\n" + + " * [`verify` [*targets*]](#verify-targets)\n" + + "* [Editor configuration](#editor-configuration)\n" + + "* [Umask configuration](#umask-configuration)\n" + + "* [Template execution](#template-execution)\n" + + "* [Template variables](#template-variables)\n" + + "* [Template functions](#template-functions)\n" + + " * [`bitwarden` [*args*]](#bitwarden-args)\n" + + " * [`gopass` *gopass-name*](#gopass-gopass-name)\n" + + " * [`keepassxc` *entry*](#keepassxc-entry)\n" + + " * [`keepassxcAttribute` *entry* *attribute*](#keepassxcattribute-entry-attribute)\n" + + " * [`keyring` *service* *user*](#keyring-service-user)\n" + + " * [`lastpass` *id*](#lastpass-id)\n" + + " * [`lastpassRaw` *id*](#lastpassraw-id)\n" + + " * [`onepassword` *uuid*](#onepassword-uuid)\n" + + " * [`onepasswordDocument` *uuid*](#onepassworddocument-uuid)\n" + + " * [`pass` *pass-name*](#pass-pass-name)\n" + + " * [`promptString` *prompt*](#promptstring-prompt)\n" + + " * [`secret` [*args*]](#secret-args)\n" + + " * [`secretJSON` [*args*]](#secretjson-args)\n" + + " * [`vault` *key*](#vault-key)\n" + + "\n" + + "## Concepts\n" + + "\n" + + "chezmoi evaluates the source state for the current machine and then updates the\n" + + "destination directory, where:\n" + + "\n" + + "* The *source state* declares the desired state of your home directory,\n" + + " including templates and machine-specific configuration.\n" + + "\n" + + "* The *source directory* is where chezmoi stores the source state, by default\n" + + " `~/.local/share/chezmoi`.\n" + + "\n" + + "* The *target state* is the source state computed for the current machine.\n" + + "\n" + + "* The *destination directory* is the directory that chezmoi manages, by default\n" + + " `~`, your home directory.\n" + + "\n" + + "* A *target* is a file, directory, or symlink in the destination directory.\n" + + "\n" + + "* The *destination state* is the state of all the targets in the destination\n" + + " directory.\n" + + "\n" + + "* The *config file* contains machine-specific configuration, by default it is\n" + + " `~/.config/chezmoi/chezmoi.toml`.\n" + + "\n" + + "## Global command line flags\n" + + "\n" + + "Command line flags override any values set in the configuration file.\n" + + "\n" + + "### `--color` *value*\n" + + "\n" + + "Colorize diffs, *value* can be `on`, `off`, `auto`, or any boolean-like value\n" + + "recognized by\n" + + "[`strconv.ParseBool`](https://pkg.go.dev/strconv?tab=doc#ParseBool). The default\n" + + "value is `auto` which will colorize diffs only if the the environment variable\n" + + "`NO_COLOR` is not set and stdout is a terminal.\n" + + "\n" + + "### `-c`, `--config` *filename*\n" + + "\n" + + "Read the configuration from *filename*.\n" + + "\n" + + "### `--debug`\n" + + "\n" + + "Log information helpful for debugging.\n" + + "\n" + + "### `-D`, `--destination` *directory*\n" + + "\n" + + "Use *directory* as the destination directory.\n" + + "\n" + + "### `-f`, `--follow`\n" + + "\n" + + "If the last part of a target is a symlink, deal with what the symlink\n" + + "references, rather than the symlink itself.\n" + + "\n" + + "### `-n`, `--dry-run`\n" + + "\n" + + "Set dry run mode. In dry run mode, the destination directory is never modified.\n" + + "This is most useful in combination with the `-v` (verbose) flag to print changes\n" + + "that would be made without making them.\n" + + "\n" + + "### `-h`, `--help`\n" + + "\n" + + "Print help.\n" + + "\n" + + "### `-r`. `--remove`\n" + + "\n" + + "Also remove targets according to `.chezmoiremove`.\n" + + "\n" + + "### `-S`, `--source` *directory*\n" + + "\n" + + "Use *directory* as the source directory.\n" + + "\n" + + "### `-v`, `--verbose`\n" + + "\n" + + "Set verbose mode. In verbose mode, chezmoi prints the changes that it is making\n" + + "as approximate shell commands, and any differences in files between the target\n" + + "state and the destination set are printed as unified diffs.\n" + + "\n" + + "### `--version`\n" + + "\n" + + "Print the version of chezmoi, the commit at which it was built, and the build\n" + + "timestamp.\n" + + "\n" + + "## Configuration file\n" + + "\n" + + "chezmoi searches for its configuration file according to the [XDG Base Directory\n" + + "Specification](https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html)\n" + + "and supports all formats supported by\n" + + "[`github.com/spf13/viper`](https://github.com/spf13/viper), namely\n" + + "[JSON](https://www.json.org/json-en.html),\n" + + "[TOML](https://github.com/toml-lang/toml), [YAML](https://yaml.org/), macOS\n" + + "property file format, and [HCL](https://github.com/hashicorp/hcl). The basename\n" + + "of the config file is `chezmoi`, and the first config file found is used.\n" + + "\n" + + "### Configuration variables\n" + + "\n" + + "The following configuration variables are available:\n" + + "\n" + + "| Variable | Type | Default value | Description |\n" + + "| ----------------------- | -------- | ------------------------- | --------------------------------------------------- |\n" + + "| `bitwarden.command` | string | `bw` | Bitwarden CLI command |\n" + + "| `cd.args` | []string | *none* | Extra args to shell in `cd` command |\n" + + "| `cd.command` | string | *none* | Shell to run in `cd` command |\n" + + "| `color` | string | `auto` | Colorize diffs |\n" + + "| `data` | any | *none* | Template data |\n" + + "| `destDir` | string | `~` | Destination directory |\n" + + "| `diff.format` | string | `chezmoi` | Diff format, either `chezmoi` or `git` |\n" + + "| `diff.pager` | string | *none* | Pager |\n" + + "| `dryRun` | bool | `false` | Dry run mode |\n" + + "| `follow` | bool | `false` | Follow symlinks |\n" + + "| `genericSecret.command` | string | *none* | Generic secret command |\n" + + "| `gopass.command` | string | `gopass` | gopass CLI command |\n" + + "| `gpg.command` | string | `gpg` | GPG CLI command |\n" + + "| `gpg.recipient` | string | *none* | GPG recipient |\n" + + "| `gpg.symmetric` | bool | `false` | Use symmetric GPG encryption |\n" + + "| `keepassxc.args` | []string | *none* | Extra args to KeePassXC CLI command |\n" + + "| `keepassxc.command` | string | `keepassxc-cli` | KeePassXC CLI command |\n" + + "| `keepassxc.database` | string | *none* | KeePassXC database |\n" + + "| `lastpass.command` | string | `lpass` | Lastpass CLI command |\n" + + "| `merge.args` | []string | *none* | Extra args to 3-way merge command |\n" + + "| `merge.command` | string | `vimdiff` | 3-way merge command |\n" + + "| `onepassword.command` | string | `op` | 1Password CLI command |\n" + + "| `pass.command` | string | `pass` | Pass CLI command |\n" + + "| `remove` | bool | `false` | Remove targets |\n" + + "| `sourceDir` | string | `~/.local/share/chezmoi` | Source directory |\n" + + "| `sourceVCS.autoCommit` | bool | `false` | Commit changes to the source state after any change |\n" + + "| `sourceVCS.autoPush` | bool | `false` | Push changes to the source state after any change |\n" + + "| `sourceVCS.command` | string | `git` | Source version control system |\n" + + "| `template.options` | []string | `[\"missingkey=error\"]` | Template options |\n" + + "| `umask` | int | *from system* | Umask |\n" + + "| `vault.command` | string | `vault` | Vault CLI command |\n" + + "| `verbose` | bool | `false` | Verbose mode |\n" + + "\n" + + "## Source state attributes\n" + + "\n" + + "chezmoi stores the source state of files, symbolic links, and directories in\n" + + "regular files and directories in the source directory (`~/.local/share/chezmoi`\n" + + "by default). This location can be overridden with the `-S` flag or by giving a\n" + + "value for `sourceDir` in `~/.config/chezmoi/chezmoi.toml`. Some state is\n" + + "encoded in the source names. chezmoi ignores all files and directories in the\n" + + "source directory that begin with a `.`. The following prefixes and suffixes are\n" + + "special, and are collectively referred to as \"attributes\":\n" + + "\n" + + "| Prefix | Effect |\n" + + "| ------------ | ------------------------------------------------------------------------------ |\n" + + "| `encrypted_` | Encrypt the file in the source state. |\n" + + "| `once_` | Only run script once. |\n" + + "| `private_` | Remove all group and world permissions from the target file or directory. |\n" + + "| `empty_` | Ensure the file exists, even if is empty. By default, empty files are removed. |\n" + + "| `exact_` | Remove anything not managed by chezmoi. |\n" + + "| `executable_`| Add executable permissions to the target file. |\n" + + "| `run_` | Treat the contents as a script to run. |\n" + + "| `symlink_` | Create a symlink instead of a regular file. |\n" + + "| `dot_` | Rename to use a leading dot, e.g. `dot_foo` becomes `.foo`. |\n" + + "\n" + + "| Suffix | Effect |\n" + + "| ------- | ---------------------------------------------------- |\n" + + "| `.tmpl` | Treat the contents of the source file as a template. |\n" + + "\n" + + "Order of prefixes is important, the order is `run_`, `exact_`, `private_`,\n" + + "`empty_`, `executable_`, `symlink_`, `once_`, `dot_`.\n" + + "\n" + + "Different target types allow different prefixes and suffixes:\n" + + "\n" + + "| Target type | Allowed prefixes | Allowed suffixes |\n" + + "| ------------- | --------------------------------------------------------- | ---------------- |\n" + + "| Directory | `exact_`, `private_`, `dot_` | *none* |\n" + + "| Regular file | `encrypted_`, `private_`, `empty_`, `executable_`, `dot_` | `.tmpl` |\n" + + "| Script | `run_`, `once_` | `.tmpl` |\n" + + "| Symbolic link | `symlink_`, `dot_`, | `.tmpl` |\n" + + "\n" + + "## Special files and directories\n" + + "\n" + + "All files and directories in the source state whose name begins with `.` are\n" + + "ignored by default, unless they are one of the special files listed here.\n" + + "\n" + + "### `.chezmoi..tmpl`\n" + + "\n" + + "If a file called `.chezmoi..tmpl` exists then `chezmoi init` will use it\n" + + "to create an initial config file. *format* must be one of the the supported\n" + + "config file formats.\n" + + "\n" + + "#### `.chezmoi..tmpl` examples\n" + + "\n" + + " {{ $email := promptString \"email\" -}}\n" + + " data:\n" + + " email: \"{{ $email }}\"\n" + + "\n" + + "### `.chezmoiignore`\n" + + "\n" + + "If a file called `.chezmoiignore` exists in the source state then it is\n" + + "interpreted as a set of patterns to ignore. Patterns are matched using\n" + + "[`doublestar.PathMatch`](https://pkg.go.dev/github.com/bmatcuk/doublestar?tab=doc#PathMatch)\n" + + "and match against the target path, not the source path.\n" + + "\n" + + "Patterns can be excluded by prefixing them with a `!` character. All excludes\n" + + "take priority over all includes.\n" + + "\n" + + "Comments are introduced with the `#` character and run until the end of the\n" + + "line.\n" + + "\n" + + "`.chezmoiignore` is interpreted as a template. This allows different files to be\n" + + "ignored on different machines.\n" + + "\n" + + "`.chezmoiignore` files in subdirectories apply only to that subdirectory.\n" + + "\n" + + "#### `.chezmoiignore` examples\n" + + "\n" + + " README.md\n" + + "\n" + + " *.txt # ignore *.txt in the target directory\n" + + " */*.txt # ignore *.txt in subdirectories of the target directory\n" + + "\n" + + " {{- if ne .email \"john.smith@company.com\" }}\n" + + " # Ignore .company-directory unless configured with a company email\n" + + " .company-directory # note that the pattern is not dot_company-directory\n" + + " {{- end }}\n" + + "\n" + + " {{- if ne .email \"john@home.org }}\n" + + " .personal-file\n" + + " {{- end }}\n" + + "\n" + + "### `.chezmoiremove`\n" + + "\n" + + "If a file called `.chezmoiremove` exists in the source state then it is\n" + + "interpreted as a list of targets to remove. `.chezmoiremove` is interpreted as a\n" + + "template.\n" + + "\n" + + "### `.chezmoitemplates`\n" + + "\n" + + "If a directory called `.chezmoitemplates` exists, then all files in this\n" + + "directory are parsed as templates are available as templates with a name equal\n" + + "to the relative path of the file.\n" + + "\n" + + "#### `.chezmoitemplates` examples\n" + + "\n" + + "Given:\n" + + "\n" + + " .chezmoitemplates/foo\n" + + " {{ if true }}bar{{ end }}\n" + + "\n" + + " dot_config.tmpl\n" + + " {{ template \"foo\" }}\n" + + "\n" + + "The target state of `.config` will be `bar`.\n" + + "\n" + + "### `.chezmoiversion`\n" + + "\n" + + "If a file called `.chezmoiversion` exists, then its contents are interpreted as\n" + + "a semantic version defining the minimum version of chezmoi required to interpret\n" + + "the source state correctly. chezmoi will refuse to interpret the source state if\n" + + "the current version is too old.\n" + + "\n" + + "**Warning** support for `.chezmoiversion` will be introduced in a future version\n" + + "(likely 1.5.0). Earlier versions of chezmoi will ignore this file.\n" + + "\n" + + "#### `.chezmoiversion` examples\n" + + "\n" + + " 1.5.0\n" + + "\n" + + "## Commands\n" + + "\n" + + "### `add` *targets*\n" + + "\n" + + "Add *targets* to the source state. If any target is already in the source state,\n" + + "then its source state is replaced with its current state in the destination\n" + + "directory. The `add` command accepts additional flags:\n" + + "\n" + + "#### `--autotemplate`\n" + + "\n" + + "Automatically generate a template by replacing strings with variable names from\n" + + "the `data` section of the config file. Longer substitutions occur before shorter\n" + + "ones. This implies the `--template` option.\n" + + "\n" + + "#### `-e`, `--empty`\n" + + "\n" + + "Set the `empty` attribute on added files.\n" + + "\n" + + "#### `-f`, `--force`\n" + + "\n" + + "Add *targets*, even if doing so would cause a source template to be overwritten.\n" + + "\n" + + "#### `-x`, `--exact`\n" + + "\n" + + "Set the `exact` attribute on added directories.\n" + + "\n" + + "#### `-p`, `--prompt`\n" + + "\n" + + "Interactively prompt before adding each file.\n" + + "\n" + + "#### `-r`, `--recursive`\n" + + "\n" + + "Recursively add all files, directories, and symlinks.\n" + + "\n" + + "#### `-T`, `--template`\n" + + "\n" + + "Set the `template` attribute on added files and symlinks.\n" + + "\n" + + "#### `add` examples\n" + + "\n" + + " chezmoi add ~/.bashrc\n" + + " chezmoi add ~/.gitconfig --template\n" + + " chezmoi add ~/.vim --recursive\n" + + " chezmoi add ~/.oh-my-zsh --exact --recursive\n" + + "\n" + + "### `apply` [*targets*]\n" + + "\n" + + "Ensure that *targets* are in the target state, updating them if necessary. If no\n" + + "targets are specified, the state of all targets are ensured.\n" + + "\n" + + "#### `apply` examples\n" + + "\n" + + " chezmoi apply\n" + + " chezmoi apply --dry-run --verbose\n" + + " chezmoi apply ~/.bashrc\n" + + "\n" + + "### `archive`\n" + + "\n" + + "Generate a tar archive of the target state. This can be piped into `tar` to\n" + + "inspect the target state.\n" + + "\n" + + "#### `--output`, `-o` *filename*\n" + + "\n" + + "Write the output to *filename* instead of stdout.\n" + + "\n" + + "#### `archive` examples\n" + + "\n" + + " chezmoi archive | tar tvf -\n" + + " chezmoi archive --output=dotfiles.tar\n" + + "\n" + + "### `cat` targets\n" + + "\n" + + "Write the target state of *targets* to stdout. *targets* must be files or\n" + + "symlinks. For files, the target file contents are written. For symlinks, the\n" + + "target target is written.\n" + + "\n" + + "#### `cat` examples\n" + + "\n" + + " chezmoi cat ~/.bashrc\n" + + "\n" + + "### `cd`\n" + + "\n" + + "Launch a shell in the source directory. chezmoi will launch the command set by\n" + + "the `cd.command` configuration variable with any extra arguments specified by\n" + + "`cd.args`. If this is not set, chezmoi will attempt to detect your shell and\n" + + "will finally fall back to an OS-specific default.\n" + + "\n" + + "#### `cd` examples\n" + + "\n" + + " chezmoi cd\n" + + "\n" + + "### `chattr` *attributes* *targets*\n" + + "\n" + + "Change the attributes of *targets*. *attributes* specifies which attributes to\n" + + "modify. Add attributes by specifying them or their abbreviations directly,\n" + + "optionally prefixed with a plus sign (`+`). Remove attributes by prefixing them\n" + + "or their attributes with the string `no` or a minus sign (`-`). The available\n" + + "attributes and their abbreviations are:\n" + + "\n" + + "| Attribute | Abbreviation |\n" + + "| ------------ | ------------ |\n" + + "| `empty` | `e` |\n" + + "| `encrypted` | *none* |\n" + + "| `exact` | *none* |\n" + + "| `executable` | `x` |\n" + + "| `private` | `p` |\n" + + "| `template` | `t` |\n" + + "\n" + + "Multiple attributes modifications may be specified by separating them with a\n" + + "comma (`,`).\n" + + "\n" + + "#### `chattr` examples\n" + + "\n" + + " chezmoi chattr template ~/.bashrc\n" + + " chezmoi chattr noempty ~/.profile\n" + + " chezmoi chattr private,template ~/.netrc\n" + + "\n" + + "### `completion` *shell*\n" + + "\n" + + "Generate shell completion code for the specified shell (`bash`, `fish`, or\n" + + "`zsh`).\n" + + "\n" + + "#### `--output`, `-o` *filename*\n" + + "\n" + + "Write the shell completion code to *filename* instead of stdout.\n" + + "\n" + + "#### `completion` examples\n" + + "\n" + + " chezmoi completion bash\n" + + " chezmoi completion fish --output ~/.config/fish/completions/chezmoi.fish\n" + + "\n" + + "### `data`\n" + + "\n" + + "Write the computed template data in JSON format to stdout. The `data` command\n" + + "accepts additional flags:\n" + + "\n" + + "#### `-f`, `--format` *format*\n" + + "\n" + + "Print the computed template data in the given format. The accepted formats are\n" + + "`json` (JSON), `toml` (TOML), and `yaml` (YAML).\n" + + "\n" + + "#### `data` examples\n" + + "\n" + + " chezmoi data\n" + + " chezmoi data --format=yaml\n" + + "\n" + + "### `diff` [*targets*]\n" + + "\n" + + "Print the difference between the target state and the destination state for\n" + + "*targets*. If no targets are specified, print the differences for all targets.\n" + + "\n" + + "If a `diff.pager` command is set in the configuration file then the output will\n" + + "be piped into it.\n" + + "\n" + + "#### `-f`, `--format` *format*\n" + + "\n" + + "Print the diff in *format*. The format can be set with the `diff.format`\n" + + "variable in the configuration file. Valid formats are:\n" + + "\n" + + "##### `chezmoi`\n" + + "\n" + + "A mix of unified diffs and pseudo shell commands, including scripts, equivalent\n" + + "to `chezmoi apply --dry-run --verbose`.\n" + + "\n" + + "##### `git`\n" + + "\n" + + "A [git format diff](https://git-scm.com/docs/diff-format), excluding scripts. In\n" + + "version 2.0.0 of chezmoi, `git` format diffs will become the default and include\n" + + "scripts and the `chezmoi` format will be removed.\n" + + "\n" + + "#### `--no-pager`\n" + + "\n" + + "Do not use the pager.\n" + + "\n" + + "#### `diff` examples\n" + + "\n" + + " chezmoi diff\n" + + " chezmoi diff ~/.bashrc\n" + + " chezmoi diff --format=git\n" + + "\n" + + "### `docs` [*regexp*]\n" + + "\n" + + "Print the documentation page matching the regular expression *regexp*. Matching\n" + + "is case insensitive. If no pattern is given, print `REFERENCE.md`.\n" + + "\n" + + "#### `docs` examples\n" + + "\n" + + " chezmoi docs\n" + + " chezmoi docs faq\n" + + " chezmoi docs howto\n" + + "\n" + + "### `doctor`\n" + + "\n" + + "Check for potential problems.\n" + + "\n" + + "#### `doctor` examples\n" + + "\n" + + " chezmoi doctor\n" + + "\n" + + "### `dump` [*targets*]\n" + + "\n" + + "Dump the target state in JSON format. If no targets are specified, then the\n" + + "entire target state. The `dump` command accepts additional arguments:\n" + + "\n" + + "#### `-f`, `--format` *format*\n" + + "\n" + + "Print the target state in the given format. The accepted formats are `json`\n" + + "(JSON) and `yaml` (YAML).\n" + + "\n" + + "#### `dump` examples\n" + + "\n" + + " chezmoi dump ~/.bashrc\n" + + " chezmoi dump --format=yaml\n" + + "\n" + + "### `edit` [*targets*]\n" + + "\n" + + "Edit the source state of *targets*, which must be files or symlinks. If no\n" + + "targets are given the the source directory itself is opened with `$EDITOR`. The\n" + + "`edit` command accepts additional arguments:\n" + + "\n" + + "#### `-a`, `--apply`\n" + + "\n" + + "Apply target immediately after editing. Ignored if there are no targets.\n" + + "\n" + + "#### `-d`, `--diff`\n" + + "\n" + + "Print the difference between the target state and the actual state after\n" + + "editing.. Ignored if there are no targets.\n" + + "\n" + + "#### `-p`, `--prompt`\n" + + "\n" + + "Prompt before applying each target.. Ignored if there are no targets.\n" + + "\n" + + "#### `edit` examples\n" + + "\n" + + " chezmoi edit ~/.bashrc\n" + + " chezmoi edit ~/.bashrc --apply --prompt\n" + + " chezmoi edit\n" + + "\n" + + "### `edit-config`\n" + + "\n" + + "Edit the configuration file.\n" + + "\n" + + "#### `edit-config` examples\n" + + "\n" + + " chezmoi edit-config\n" + + "\n" + + "### `execute-template` [*templates*]\n" + + "\n" + + "Execute *templates*. This is useful for testing templates or for calling chezmoi\n" + + "from other scripts. *templates* are interpreted as literal templates, with no\n" + + "whitespace added to the output between arguments. If no templates are specified,\n" + + "the template is read from stdin.\n" + + "\n" + + "#### `--init`, `-i`\n" + + "\n" + + "Include simulated functions only available during `chezmoi init`.\n" + + "\n" + + "#### '--output', '-o' *filename*\n" + + "\n" + + "Write the output to *filename* instead of stdout.\n" + + "\n" + + "#### `--promptString`, `-p` *pairs*\n" + + "\n" + + "Simulate the `promptString` function with a function that returns values from\n" + + "*pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If\n" + + "`promptString` is called with a *prompt* that does not match any of *pairs*,\n" + + "then it returns *prompt* unchanged.\n" + + "\n" + + "#### `execute-template` examples\n" + + "\n" + + " chezmoi execute-template '{{ .chezmoi.sourceDir }}'\n" + + " chezmoi execute-template '{{ .chezmoi.os }}' / '{{ .chezmoi.arch }}'\n" + + " echo '{{ .chezmoi | toJson }}' | chezmoi execute-template\n" + + " chezmoi execute-template --init --promptString email=john@home.org < ~/.local/share/chezmoi/.chezmoi.toml.tmpl\n" + + "\n" + + "### `forget` *targets*\n" + + "\n" + + "Remove *targets* from the source state, i.e. stop managing them.\n" + + "\n" + + "#### `forget` examples\n" + + "\n" + + " chezmoi forget ~/.bashrc\n" + + "\n" + + "### `git` [*arguments*]\n" + + "\n" + + "Run `git` *arguments* in the source directory. Note that flags in *arguments*\n" + + "must occur after `--` to prevent chezmoi from interpreting them.\n" + + "\n" + + "#### `git` examples\n" + + "\n" + + " chezmoi git add .\n" + + " chezmoi git add dot_gitconfig\n" + + " chezmoi git -- commit -m \"Add .gitconfig\"\n" + + "\n" + + "### `help` *command*\n" + + "\n" + + "Print the help associated with *command*.\n" + + "\n" + + "### `hg` [*arguments*]\n" + + "\n" + + "Run `hg` *arguments* in the source directory. Note that flags in *arguments*\n" + + "must occur after `--` to prevent chezmoi from interpreting them.\n" + + "\n" + + "#### `hg` examples\n" + + "\n" + + " chezmoi hg -- pull --rebase --update\n" + + "\n" + + "### `init` [*repo*]\n" + + "\n" + + "Setup the source directory and update the destination directory to match the\n" + + "target state. If *repo* is given then it is checked out into the source\n" + + "directory, otherwise a new repository is initialized in the source directory. If\n" + + "a file called `.chezmoi.format.tmpl` exists, where `format` is one of the\n" + + "supported file formats (e.g. `json`, `toml`, or `yaml`) then a new configuration\n" + + "file is created using that file as a template. Finally, if the `--apply` flag is\n" + + "passed, `chezmoi apply` is run.\n" + + "\n" + + "#### `init` examples\n" + + "\n" + + " chezmoi init https://github.com/user/dotfiles.git\n" + + " chezmoi init https://github.com/user/dotfiles.git --apply\n" + + "\n" + + "### `import` *filename*\n" + + "\n" + + "Import the source state from an archive file in to a directory in the source\n" + + "state. This is primarily used to make subdirectories of your home directory\n" + + "exactly match the contents of a downloaded archive. You will generally always\n" + + "want to set the `--destination`, `--exact`, and `--remove-destination` flags.\n" + + "\n" + + "The only supported archive format is `.tar.gz`.\n" + + "\n" + + "#### `--destination` *directory*\n" + + "\n" + + "Set the destination (in the source state) where the archive will be imported.\n" + + "\n" + + "#### `-x`, `--exact`\n" + + "\n" + + "Set the `exact` attribute on all imported directories.\n" + + "\n" + + "#### `-r`, `--remove-destination`\n" + + "\n" + + "Remove destination (in the source state) before importing.\n" + + "\n" + + "#### `--strip-components` *n*\n" + + "\n" + + "Strip *n* leading components from paths.\n" + + "\n" + + "#### `import` examples\n" + + "\n" + + " curl -s -L -o oh-my-zsh-master.tar.gz https://github.com/robbyrussell/oh-my-zsh/archive/master.tar.gz\n" + + " chezmoi import --strip-components 1 --destination ~/.oh-my-zsh oh-my-zsh-master.tar.gz\n" + + "\n" + + "### `manage` *targets*\n" + + "\n" + + "`manage` is an alias for `add` for symmetry with `unmanage`.\n" + + "\n" + + "### `managed`\n" + + "\n" + + "List all managed entries in the destination directory in alphabetical order.\n" + + "\n" + + "#### `-i`, `--include` *types*\n" + + "\n" + + "Only list entries of type *types*. *types* is a comma-separated list of types of\n" + + "entry to include. Valid types are `dirs`, `files`, and `symlinks` which can be\n" + + "abbreviated to `d`, `f`, and `s` respectively. By default, `manage` will list\n" + + "entries of all types.\n" + + "\n" + + "#### `managed` examples\n" + + "\n" + + " chezmoi managed\n" + + " chezmoi managed --include=files\n" + + " chezmoi managed --include=files,symlinks\n" + + " chezmoi managed -i d\n" + + " chezmoi managed -i d,f\n" + + "\n" + + "### `merge` *targets*\n" + + "\n" + + "Perform a three-way merge between the destination state, the source state, and\n" + + "the target state. The merge tool is defined by the `merge.command` configuration\n" + + "variable, and defaults to `vimdiff`. If multiple targets are specified the merge\n" + + "tool is invoked for each target. If the target state cannot be computed (for\n" + + "example if source is a template containing errors or an encrypted file that\n" + + "cannot be decrypted) a two-way merge is performed instead.\n" + + "\n" + + "#### `merge` examples\n" + + "\n" + + " chezmoi merge ~/.bashrc\n" + + "\n" + + "### `purge`\n" + + "\n" + + "Remove chezmoi's configuration, state, and source directory, but leave the\n" + + "target state intact.\n" + + "\n" + + "#### `-f`, `--force`\n" + + "\n" + + "Remove without prompting.\n" + + "\n" + + "#### `purge` examples\n" + + "\n" + + " chezmoi purge\n" + + " chezmoi purge --force\n" + + "\n" + + "### `remove` *targets*\n" + + "\n" + + "Remove *targets* from both the source state and the destination directory.\n" + + "\n" + + "#### `-f`, `--force`\n" + + "\n" + + "Remove without prompting.\n" + + "\n" + + "### `rm` *targets*\n" + + "\n" + + "`rm` is an alias for `remove`.\n" + + "\n" + + "### `secret`\n" + + "\n" + + "Run a secret manager's CLI, passing any extra arguments to the secret manager's\n" + + "CLI. This is primarily for verifying chezmoi's integration with your secret\n" + + "manager. Normally you would use template functions to retrieve secrets. Note\n" + + "that if you want to pass flags to the secret manager's CLI you will need to\n" + + "separate them with `--` to prevent chezmoi from interpreting them.\n" + + "\n" + + "To get a full list of available commands run:\n" + + "\n" + + " chezmoi secret help\n" + + "\n" + + "#### `secret` examples\n" + + "\n" + + " chezmoi secret bitwarden list items\n" + + " chezmoi secret keyring set --service service --user user\n" + + " chezmoi secret keyring get --service service --user user\n" + + " chezmoi secret lastpass ls\n" + + " chezmoi secret lastpass -- show --format=json id\n" + + " chezmoi secret onepassword list items\n" + + " chezmoi secret onepassword get item id\n" + + " chezmoi secret pass show id\n" + + " chezmoi secret vault -- kv get -format=json id\n" + + "\n" + + "### `source` [*args*]\n" + + "\n" + + "Execute the source version control system in the source directory with *args*.\n" + + "Note that any flags for the source version control system must be separated with\n" + + "a `--` to stop chezmoi from reading them.\n" + + "\n" + + "#### `source` examples\n" + + "\n" + + " chezmoi source init\n" + + " chezmoi source add .\n" + + " chezmoi source commit -- -m \"Initial commit\"\n" + + "\n" + + "### `source-path` [*targets*]\n" + + "\n" + + "Print the path to each target's source state. If no targets are specified then\n" + + "print the source directory.\n" + + "\n" + + "#### `source-path` examples\n" + + "\n" + + " chezmoi source-path\n" + + " chezmoi source-path ~/.bashrc\n" + + "\n" + + "### `unmanage` *targets*\n" + + "\n" + + "`unmanage` is an alias for `forget` for symmetry with `manage`.\n" + + "\n" + + "### `unmanaged`\n" + + "\n" + + "List all unmanaged files in the destination directory.\n" + + "\n" + + "#### `unmanaged` examples\n" + + "\n" + + " chezmoi unmanaged\n" + + "\n" + + "### `update`\n" + + "\n" + + "Pull changes from the source VCS and apply any changes.\n" + + "\n" + + "#### `update` examples\n" + + "\n" + + " chezmoi update\n" + + "\n" + + "### `upgrade`\n" + + "\n" + + "Upgrade chezmoi by downloading and installing the latest released version. This\n" + + "will call the GitHub API to determine if there is a new version of chezmoi\n" + + "available, and if so, download and attempt to install it in the same way as\n" + + "chezmoi was previously installed.\n" + + "\n" + + "If chezmoi was installed with a package manager (`dpkg` or `rpm`) then `upgrade`\n" + + "will download a new package and install it, using `sudo` if it is installed.\n" + + "Otherwise, chezmoi will download the latest executable and replace the existing\n" + + "executable with the new version.\n" + + "\n" + + "If the `CHEZMOI_GITHUB_API_TOKEN` environment variable is set, then its value\n" + + "will be used to authenticate requests to the GitHub API, otherwise\n" + + "unauthenticated requests are used which are subject to stricter [rate\n" + + "limiting](https://developer.github.com/v3/#rate-limiting). Unauthenticated\n" + + "requests should be sufficient for most cases.\n" + + "\n" + + "#### `upgrade` examples\n" + + "\n" + + " chezmoi upgrade\n" + + "\n" + + "### `verify` [*targets*]\n" + + "\n" + + "Verify that all *targets* match their target state. chezmoi exits with code 0\n" + + "(success) if all targets match their target state, or 1 (failure) otherwise. If\n" + + "no targets are specified then all targets are checked.\n" + + "\n" + + "#### `verify` examples\n" + + "\n" + + " chezmoi verify\n" + + " chezmoi verify ~/.bashrc\n" + + "\n" + + "## Editor configuration\n" + + "\n" + + "The `edit` and `edit-config` commands use the editor specified by the `VISUAL`\n" + + "environment variable, the `EDITOR` environment variable, or `vi`, whichever is\n" + + "specified first.\n" + + "\n" + + "## Umask configuration\n" + + "\n" + + "By default, chezmoi uses your current umask as set by your operating system and\n" + + "shell. chezmoi only stores crude permissions in its source state, namely in the\n" + + "`executable` and `private` attributes, corresponding to the umasks of `0o111`\n" + + "and `0o077` respectively.\n" + + "\n" + + "For machine-specific control of umask, set the `umask` configuration variable in\n" + + "chezmoi's configuration file, for example:\n" + + "\n" + + " umask = 0o22\n" + + "\n" + + "## Template execution\n" + + "\n" + + "chezmoi executes templates using\n" + + "[`text/template`](https://pkg.go.dev/text/template). The result is treated\n" + + "differently depending on whether the target is a file or a symlink.\n" + + "\n" + + "If target is a file, then:\n" + + "\n" + + "* If the result is an empty string, then the file is removed.\n" + + "* Otherwise, the target file contents are result.\n" + + "\n" + + "If the target is a symlink, then:\n" + + "\n" + + "* Leading and trailing whitespace are stripped from the result.\n" + + "* If the result is an empty string, then the symlink is removed.\n" + + "* Otherwise, the target symlink target is the result.\n" + + "\n" + + "chezmoi executes templates using `text/template`'s `missingkey=error` option,\n" + + "which means that misspelled or missing keys will raise an error. This can be\n" + + "overridden by setting a list of options in the configuration file, for example:\n" + + "\n" + + " [template]\n" + + " options = [\"missingkey=zero\"]\n" + + "\n" + + "For a full list of options, see\n" + + "[`Template.Option`](https://pkg.go.dev/text/template?tab=doc#Template.Option).\n" + + "\n" + + "## Template variables\n" + + "\n" + + "chezmoi provides the following automatically populated variables:\n" + + "\n" + + "| Variable | Value |\n" + + "| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |\n" + + "| `.chezmoi.arch` | Architecture, e.g. `amd64`, `arm`, etc. as returned by [runtime.GOARCH](https://pkg.go.dev/runtime?tab=doc#pkg-constants). |\n" + + "| `.chezmoi.fullHostname` | The full hostname of the machine chezmoi is running on. |\n" + + "| `.chezmoi.group` | The group of the user running chezmoi. |\n" + + "| `.chezmoi.homedir` | The home directory of the user running chezmoi. |\n" + + "| `.chezmoi.hostname` | The hostname of the machine chezmoi is running on, up to the first `.`. |\n" + + "| `.chezmoi.kernel` | Contains information from `/proc/sys/kernel`. Linux only, useful for detecting specific kernels (i.e. Microsoft's WSL kernel). |\n" + + "| `.chezmoi.os` | Operating system, e.g. `darwin`, `linux`, etc. as returned by [runtime.GOOS](https://pkg.go.dev/runtime?tab=doc#pkg-constants). |\n" + + "| `.chezmoi.osRelease` | The information from `/etc/os-release`, Linux only, run `chezmoi data` to see its output. |\n" + + "| `.chezmoi.sourceDir` | The source directory. |\n" + + "| `.chezmoi.username` | The username of the user running chezmoi. |\n" + + "\n" + + "Additional variables can be defined in the config file in the `data` section.\n" + + "Variable names must consist of a letter and be followed by zero or more letters\n" + + "and/or digits.\n" + + "\n" + + "## Template functions\n" + + "\n" + + "All standard [`text/template`](https://pkg.go.dev/text/template) and [text\n" + + "template functions from `sprig`](http://masterminds.github.io/sprig/) are\n" + + "included. chezmoi provides some additional functions.\n" + + "\n" + + "### `bitwarden` [*args*]\n" + + "\n" + + "`bitwarden` returns structured data retrieved from\n" + + "[Bitwarden](https://bitwarden.com) using the [Bitwarden\n" + + "CLI](https://github.com/bitwarden/cli) (`bw`). *args* are passed to `bw`\n" + + "unchanged and the output from `bw` is parsed as JSON. The output from `bw` is\n" + + "cached so calling `bitwarden` multiple times with the same arguments will only\n" + + "invoke `bw` once.\n" + + "\n" + + "#### `bitwarden` examples\n" + + "\n" + + " username = {{ (bitwarden \"item\" \"example.com\").login.username }}\n" + + " password = {{ (bitwarden \"item\" \"example.com\").login.password }}\n" + + "\n" + + "### `gopass` *gopass-name*\n" + + "\n" + + "`gopass` returns passwords stored in [gopass](https://www.gopass.pw/) using the\n" + + "gopass CLI (`gopass`). *gopass-name* is passed to `gopass show `\n" + + "and first line of the output of `gopass` is returned with the trailing newline\n" + + "stripped. The output from `gopass` is cached so calling `gopass` multiple times\n" + + "with the same *gopass-name* will only invoke `gopass` once.\n" + + "\n" + + "#### `gopass` examples\n" + + "\n" + + " {{ gopass \"\" }}\n" + + "\n" + + "### `keepassxc` *entry*\n" + + "\n" + + "`keepassxc` returns structured data retrieved from a\n" + + "[KeePassXC](https://keepassxc.org/) database using the KeePassXC CLI\n" + + "(`keepassxc-cli`). The database is configured by setting `keepassxc.database` in\n" + + "the configuration file. *database* and *entry* are passed to `keepassxc-cli\n" + + "show`. You will be prompted for the database password the first time\n" + + "`keepassxc-cli` is run, and the password is cached, in plain text, in memory\n" + + "until chezmoi terminates. The output from `keepassxc-cli` is parsed into\n" + + "key-value pairs and cached so calling `keepassxc` multiple times with the same\n" + + "*entry* will only invoke `keepassxc-cli` once.\n" + + "\n" + + "#### `keepassxc` examples\n" + + "\n" + + " username = {{ (keepassxc \"example.com\").UserName }}\n" + + " password = {{ (keepassxc \"example.com\").Password }}\n" + + "\n" + + "### `keepassxcAttribute` *entry* *attribute*\n" + + "\n" + + "`keepassxcAttribute` returns the attribute *attribute* of *entry* using\n" + + "`keepassxc-cli`, with any leading or trailing whitespace removed. It behaves\n" + + "identically to the `keepassxc` function in terms of configuration, password\n" + + "prompting, password storage, and result caching.\n" + + "\n" + + "#### `keepassxcAttribute` examples\n" + + "\n" + + " {{ keepassxcAttribute \"SSH Key\" \"private-key\" }}\n" + + "\n" + + "### `keyring` *service* *user*\n" + + "\n" + + "`keyring` retrieves the password associated with *service* and *user* from the\n" + + "user's keyring.\n" + + "\n" + + "| OS | Keyring |\n" + + "| ----- | ------------- |\n" + + "| macOS | Keychain |\n" + + "| Linux | GNOME Keyring |\n" + + "\n" + + "#### `keyring` examples\n" + + "\n" + + " [github]\n" + + " user = \"{{ .github.user }}\"\n" + + " token = \"{{ keyring \"github\" .github.user }}\"\n" + + "\n" + + "### `lastpass` *id*\n" + + "\n" + + "`lastpass` returns structured data from [LastPass](https://lastpass.com) using\n" + + "the [LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html)\n" + + "(`lpass`). *id* is passed to `lpass show --json ` and the output from\n" + + "`lpass` is parsed as JSON. In addition, the `note` field, if present, is further\n" + + "parsed as colon-separated key-value pairs. The structured data is an array so\n" + + "typically the `index` function is used to extract the first item. The output\n" + + "from `lastpass` is cached so calling `lastpass` multiple times with the same\n" + + "*id* will only invoke `lpass` once.\n" + + "\n" + + "#### `lastpass` examples\n" + + "\n" + + " githubPassword = \"{{ (index (lastpass \"GitHub\") 0).password }}\"\n" + + " {{ (index (lastpass \"SSH\") 0).note.privateKey }}\n" + + "\n" + + "### `lastpassRaw` *id*\n" + + "\n" + + "`lastpassRaw` returns structured data from [LastPass](https://lastpass.com)\n" + + "using the [LastPass CLI](https://lastpass.github.io/lastpass-cli/lpass.1.html)\n" + + "(`lpass`). It behaves identically to the `lastpass` function, except that no\n" + + "further parsing is done on the `note` field.\n" + + "\n" + + "#### `lastpassRaw` examples\n" + + "\n" + + " {{ (index (lastpassRaw \"SSH Private Key\") 0).note }}\n" + + "\n" + + "### `onepassword` *uuid*\n" + + "\n" + + "`onepassword` returns structured data from [1Password](https://1password.com/)\n" + + "using the [1Password\n" + + "CLI](https://support.1password.com/command-line-getting-started/) (`op`). *uuid*\n" + + "is passed to `op get item ` and the output from `op` is parsed as JSON.\n" + + "The output from `op` is cached so calling `onepassword` multiple times with the\n" + + "same *uuid* will only invoke `op` once.\n" + + "\n" + + "#### `onepassword` examples\n" + + "\n" + + " {{ (onepassword \"\").details.password }}\n" + + "\n" + + "### `onepasswordDocument` *uuid*\n" + + "\n" + + "`onepassword` returns a document from [1Password](https://1password.com/)\n" + + "using the [1Password\n" + + "CLI](https://support.1password.com/command-line-getting-started/) (`op`). *uuid*\n" + + "is passed to `op get document ` and the output from `op` is returned.\n" + + "The output from `op` is cached so calling `onepasswordDocument` multiple times with the\n" + + "same *uuid* will only invoke `op` once.\n" + + "\n" + + "#### `onepasswordDocument` examples\n" + + "\n" + + " {{- onepasswordDocument \"\" -}}\n" + + "\n" + + "### `pass` *pass-name*\n" + + "\n" + + "`pass` returns passwords stored in [pass](https://www.passwordstore.org/) using\n" + + "the pass CLI (`pass`). *pass-name* is passed to `pass show ` and\n" + + "first line of the output of `pass` is returned with the trailing newline\n" + + "stripped. The output from `pass` is cached so calling `pass` multiple times with\n" + + "the same *pass-name* will only invoke `pass` once.\n" + + "\n" + + "#### `pass` examples\n" + + "\n" + + " {{ pass \"\" }}\n" + + "\n" + + "### `promptString` *prompt*\n" + + "\n" + + "`promptString` takes a single argument is a string prompted to the user, and the\n" + + "return value is the user's response to that prompt with all leading and trailing\n" + + "space stripped. It is only available when generating the initial config file.\n" + + "\n" + + "#### `promptString` examples\n" + + "\n" + + " {{ $email := promptString \"email\" -}}\n" + + " [data]\n" + + " email = \"{{ $email }}\"\n" + + "\n" + + "### `secret` [*args*]\n" + + "\n" + + "`secret` returns the output of the generic secret command defined by the\n" + + "`genericSecret.command` configuration variable with *args* with leading and\n" + + "trailing whitespace removed. The output is cached so multiple calls to `secret`\n" + + "with the same *args* will only invoke the generic secret command once.\n" + + "\n" + + "### `secretJSON` [*args*]\n" + + "\n" + + "`secretJSON` returns structured data from the generic secret command defined by\n" + + "the `genericSecret.command` configuration variable with *args*. The output is\n" + + "parsed as JSON. The output is cached so multiple calls to `secret` with the same\n" + + "*args* will only invoke the generic secret command once.\n" + + "\n" + + "### `vault` *key*\n" + + "\n" + + "`vault` returns structured data from [Vault](https://www.vaultproject.io/) using\n" + + "the [Vault CLI](https://www.vaultproject.io/docs/commands/) (`vault`). *key* is\n" + + "passed to `vault kv get -format=json ` and the output from `vault` is\n" + + "parsed as JSON. The output from `vault` is cached so calling `vault` multiple\n" + + "times with the same *key* will only invoke `vault` once.\n" + + "\n" + + "#### `vault` examples\n" + + "\n" + + " {{ (vault \"\").data.data.password }}\n") +} diff --git a/v2/cmd/helps.gen.go b/v2/cmd/helps.gen.go new file mode 100644 index 000000000000..044006a2dec6 --- /dev/null +++ b/v2/cmd/helps.gen.go @@ -0,0 +1,508 @@ +// Code generated by github.com/twpayne/chezmoi/internal/generate-helps. DO NOT EDIT. + +package cmd + +type help struct { + long string + example string +} + +var helps = map[string]help{ + "add": { + long: "" + + "Description:\n" + + " Add *targets* to the source state. If any target is already in the source\n" + + " state, then its source state is replaced with its current state in the\n" + + " destination directory. The `add` command accepts additional flags:\n" + + "\n" + + " `--autotemplate`\n" + + "\n" + + " Automatically generate a template by replacing strings with variable names\n" + + " from the `data` section of the config file. Longer substitutions occur before\n" + + " shorter ones. This implies the `--template` option.\n" + + "\n" + + " `-e`, `--empty`\n" + + "\n" + + " Set the `empty` attribute on added files.\n" + + "\n" + + " `-f`, `--force`\n" + + "\n" + + " Add *targets*, even if doing so would cause a source template to be\n" + + " overwritten.\n" + + "\n" + + " `-x`, `--exact`\n" + + "\n" + + " Set the `exact` attribute on added directories.\n" + + "\n" + + " `-p`, `--prompt`\n" + + "\n" + + " Interactively prompt before adding each file.\n" + + "\n" + + " `-r`, `--recursive`\n" + + "\n" + + " Recursively add all files, directories, and symlinks.\n" + + "\n" + + " `-T`, `--template`\n" + + "\n" + + " Set the `template` attribute on added files and symlinks.", + example: "" + + " chezmoi add ~/.bashrc\n" + + " chezmoi add ~/.gitconfig --template\n" + + " chezmoi add ~/.vim --recursive\n" + + " chezmoi add ~/.oh-my-zsh --exact --recursive", + }, + "apply": { + long: "" + + "Description:\n" + + " Ensure that *targets* are in the target state, updating them if necessary. If\n" + + " no targets are specified, the state of all targets are ensured.", + example: "" + + " chezmoi apply\n" + + " chezmoi apply --dry-run --verbose\n" + + " chezmoi apply ~/.bashrc", + }, + "archive": { + long: "" + + "Description:\n" + + " Generate a tar archive of the target state. This can be piped into `tar` to\n" + + " inspect the target state.\n" + + "\n" + + " `--output`, `-o` *filename*\n" + + "\n" + + " Write the output to *filename* instead of stdout.", + example: "" + + " chezmoi archive | tar tvf -\n" + + " chezmoi archive --output=dotfiles.tar", + }, + "cat": { + long: "" + + "Description:\n" + + " Write the target state of *targets* to stdout. *targets* must be files or\n" + + " symlinks. For files, the target file contents are written. For symlinks, the\n" + + " target target is written.", + example: "" + + " chezmoi cat ~/.bashrc", + }, + "cd": { + long: "" + + "Description:\n" + + " Launch a shell in the source directory. chezmoi will launch the command set by\n" + + " the `cd.command` configuration variable with any extra arguments specified by\n" + + " `cd.args`. If this is not set, chezmoi will attempt to detect your shell and\n" + + " will finally fall back to an OS-specific default.", + example: "" + + " chezmoi cd", + }, + "chattr": { + long: "" + + "Description:\n" + + " Change the attributes of *targets*. *attributes* specifies which attributes to\n" + + " modify. Add attributes by specifying them or their abbreviations directly,\n" + + " optionally prefixed with a plus sign (`+`). Remove attributes by prefixing\n" + + " them or their attributes with the string `no` or a minus sign (`-`). The\n" + + " available attributes and their abbreviations are:\n" + + "\n" + + " ATTRIBUTE | ABBREVIATION\n" + + " -------------+---------------\n" + + " empty | e\n" + + " encrypted | none\n" + + " exact | none\n" + + " executable | x\n" + + " private | p\n" + + " template | t\n" + + "\n" + + " Multiple attributes modifications may be specified by separating them with a\n" + + " comma (`,`).", + example: "" + + " chezmoi chattr template ~/.bashrc\n" + + " chezmoi chattr noempty ~/.profile\n" + + " chezmoi chattr private,template ~/.netrc", + }, + "completion": { + long: "" + + "Description:\n" + + " Generate shell completion code for the specified shell (`bash`, `fish`, or\n" + + " `zsh`).\n" + + "\n" + + " `--output`, `-o` *filename*\n" + + "\n" + + " Write the shell completion code to *filename* instead of stdout.", + example: "" + + " chezmoi completion bash\n" + + " chezmoi completion fish --output ~/.config/fish/completions/chezmoi.fish", + }, + "data": { + long: "" + + "Description:\n" + + " Write the computed template data in JSON format to stdout. The `data` command\n" + + " accepts additional flags:\n" + + "\n" + + " `-f`, `--format` *format*\n" + + "\n" + + " Print the computed template data in the given format. The accepted formats are\n" + + " `json` (JSON), `toml` (TOML), and `yaml` (YAML).", + example: "" + + " chezmoi data\n" + + " chezmoi data --format=yaml", + }, + "diff": { + long: "" + + "Description:\n" + + " Print the difference between the target state and the destination state for\n" + + " *targets*. If no targets are specified, print the differences for all targets.\n" + + "\n" + + " If a `diff.pager` command is set in the configuration file then the output\n" + + " will be piped into it.\n" + + "\n" + + " `-f`, `--format` *format*\n" + + "\n" + + " Print the diff in *format*. The format can be set with the `diff.format`\n" + + " variable in the configuration file. Valid formats are:\n" + + "\n" + + " ##### `chezmoi`\n" + + "\n" + + " A mix of unified diffs and pseudo shell commands, including scripts,\n" + + " equivalent to `chezmoi apply --dry-run --verbose`.\n" + + "\n" + + " ##### `git`\n" + + "\n" + + " A git format diff https://git-scm.com/docs/diff-format, excluding scripts. In\n" + + " version 2.0.0 of chezmoi, `git` format diffs will become the default and\n" + + " include scripts and the `chezmoi` format will be removed.\n" + + "\n" + + " `--no-pager`\n" + + "\n" + + " Do not use the pager.", + example: "" + + " chezmoi diff\n" + + " chezmoi diff ~/.bashrc\n" + + " chezmoi diff --format=git", + }, + "docs": { + long: "" + + "Description:\n" + + " Print the documentation page matching the regular expression *regexp*.\n" + + " Matching is case insensitive. If no pattern is given, print `REFERENCE.md`.", + example: "" + + " chezmoi docs\n" + + " chezmoi docs faq\n" + + " chezmoi docs howto", + }, + "doctor": { + long: "" + + "Description:\n" + + " Check for potential problems.", + example: "" + + " chezmoi doctor", + }, + "dump": { + long: "" + + "Description:\n" + + " Dump the target state in JSON format. If no targets are specified, then the\n" + + " entire target state. The `dump` command accepts additional arguments:\n" + + "\n" + + " `-f`, `--format` *format*\n" + + "\n" + + " Print the target state in the given format. The accepted formats are `json`\n" + + " (JSON) and `yaml` (YAML).", + example: "" + + " chezmoi dump ~/.bashrc\n" + + " chezmoi dump --format=yaml", + }, + "edit": { + long: "" + + "Description:\n" + + " Edit the source state of *targets*, which must be files or symlinks. If no\n" + + " targets are given the the source directory itself is opened with `$EDITOR`.\n" + + " The `edit` command accepts additional arguments:\n" + + "\n" + + " `-a`, `--apply`\n" + + "\n" + + " Apply target immediately after editing. Ignored if there are no targets.\n" + + "\n" + + " `-d`, `--diff`\n" + + "\n" + + " Print the difference between the target state and the actual state after\n" + + " editing.. Ignored if there are no targets.\n" + + "\n" + + " `-p`, `--prompt`\n" + + "\n" + + " Prompt before applying each target.. Ignored if there are no targets.", + example: "" + + " chezmoi edit ~/.bashrc\n" + + " chezmoi edit ~/.bashrc --apply --prompt\n" + + " chezmoi edit", + }, + "edit-config": { + long: "" + + "Description:\n" + + " Edit the configuration file.\n" + + "\n" + + " `edit-config` examples\n" + + "\n" + + " chezmoi edit-config", + }, + "execute-template": { + long: "" + + "Description:\n" + + " Execute *templates*. This is useful for testing templates or for calling\n" + + " chezmoi from other scripts. *templates* are interpreted as literal templates,\n" + + " with no whitespace added to the output between arguments. If no templates are\n" + + " specified, the template is read from stdin.\n" + + "\n" + + " `--init`, `-i`\n" + + "\n" + + " Include simulated functions only available during `chezmoi init`.\n" + + "\n" + + " '--output', '-o' *filename*\n" + + "\n" + + " Write the output to *filename* instead of stdout.\n" + + "\n" + + " `--promptString`, `-p` *pairs*\n" + + "\n" + + " Simulate the `promptString` function with a function that returns values from\n" + + " *pairs*. *pairs* is a comma-separated list of *prompt*`=`*value* pairs. If\n" + + " `promptString` is called with a *prompt* that does not match any of *pairs*,\n" + + " then it returns *prompt* unchanged.\n" + + "\n" + + " `execute-template` examples\n" + + "\n" + + " chezmoi execute-template '{{ .chezmoi.sourceDir }}'\n" + + " chezmoi execute-template '{{ .chezmoi.os }}' / '{{ .chezmoi.arch }}'\n" + + " echo '{{ .chezmoi | toJson }}' | chezmoi execute-template\n" + + " chezmoi execute-template --init --promptString email=john@home.org <\n" + + " ~/.local/share/chezmoi/.chezmoi.toml.tmpl", + }, + "forget": { + long: "" + + "Description:\n" + + " Remove *targets* from the source state, i.e. stop managing them.", + example: "" + + " chezmoi forget ~/.bashrc", + }, + "git": { + long: "" + + "Description:\n" + + " Run `git` *arguments* in the source directory. Note that flags in *arguments*\n" + + " must occur after `--` to prevent chezmoi from interpreting them.", + example: "" + + " chezmoi git add .\n" + + " chezmoi git add dot_gitconfig\n" + + " chezmoi git -- commit -m \"Add .gitconfig\"", + }, + "help": { + long: "" + + "Description:\n" + + " Print the help associated with *command*.", + }, + "hg": { + long: "" + + "Description:\n" + + " Run `hg` *arguments* in the source directory. Note that flags in *arguments*\n" + + " must occur after `--` to prevent chezmoi from interpreting them.", + example: "" + + " chezmoi hg -- pull --rebase --update", + }, + "import": { + long: "" + + "Description:\n" + + " Import the source state from an archive file in to a directory in the source\n" + + " state. This is primarily used to make subdirectories of your home directory\n" + + " exactly match the contents of a downloaded archive. You will generally always\n" + + " want to set the `--destination`, `--exact`, and `--remove-destination` flags.\n" + + "\n" + + " The only supported archive format is `.tar.gz`.\n" + + "\n" + + " `--destination` *directory*\n" + + "\n" + + " Set the destination (in the source state) where the archive will be imported.\n" + + "\n" + + " `-x`, `--exact`\n" + + "\n" + + " Set the `exact` attribute on all imported directories.\n" + + "\n" + + " `-r`, `--remove-destination`\n" + + "\n" + + " Remove destination (in the source state) before importing.\n" + + "\n" + + " `--strip-components` *n*\n" + + "\n" + + " Strip *n* leading components from paths.", + example: "" + + " curl -s -L -o oh-my-zsh-master.tar.gz https://github.com/robbyrussell/oh-my-\n" + + "zsh/archive/master.tar.gz\n" + + " chezmoi import --strip-components 1 --destination ~/.oh-my-zsh oh-my-zsh-master.tar.gz", + }, + "init": { + long: "" + + "Description:\n" + + " Setup the source directory and update the destination directory to match the\n" + + " target state. If *repo* is given then it is checked out into the source\n" + + " directory, otherwise a new repository is initialized in the source directory.\n" + + " If a file called `.chezmoi.format.tmpl` exists, where `format` is one of the\n" + + " supported file formats (e.g. `json`, `toml`, or `yaml`) then a new\n" + + " configuration file is created using that file as a template. Finally, if the `--\n" + + " apply` flag is passed, `chezmoi apply` is run.", + example: "" + + " chezmoi init https://github.com/user/dotfiles.git\n" + + " chezmoi init https://github.com/user/dotfiles.git --apply", + }, + "manage": { + long: "" + + "Description:\n" + + " `manage` is an alias for `add` for symmetry with `unmanage`.", + }, + "managed": { + long: "" + + "Description:\n" + + " List all managed entries in the destination directory in alphabetical order.\n" + + "\n" + + " `-i`, `--include` *types*\n" + + "\n" + + " Only list entries of type *types*. *types* is a comma-separated list of types\n" + + " of entry to include. Valid types are `dirs`, `files`, and `symlinks` which can\n" + + " be abbreviated to `d`, `f`, and `s` respectively. By default, `manage` will\n" + + " list entries of all types.", + example: "" + + " chezmoi managed\n" + + " chezmoi managed --include=files\n" + + " chezmoi managed --include=files,symlinks\n" + + " chezmoi managed -i d\n" + + " chezmoi managed -i d,f", + }, + "merge": { + long: "" + + "Description:\n" + + " Perform a three-way merge between the destination state, the source state, and\n" + + " the target state. The merge tool is defined by the `merge.command`\n" + + " configuration variable, and defaults to `vimdiff`. If multiple targets are\n" + + " specified the merge tool is invoked for each target. If the target state\n" + + " cannot be computed (for example if source is a template containing errors or\n" + + " an encrypted file that cannot be decrypted) a two-way merge is performed\n" + + " instead.", + example: "" + + " chezmoi merge ~/.bashrc", + }, + "purge": { + long: "" + + "Description:\n" + + " Remove chezmoi's configuration, state, and source directory, but leave the\n" + + " target state intact.\n" + + "\n" + + " `-f`, `--force`\n" + + "\n" + + " Remove without prompting.", + example: "" + + " chezmoi purge\n" + + " chezmoi purge --force", + }, + "remove": { + long: "" + + "Description:\n" + + " Remove *targets* from both the source state and the destination directory.\n" + + "\n" + + " `-f`, `--force`\n" + + "\n" + + " Remove without prompting.", + }, + "rm": { + long: "" + + "Description:\n" + + " `rm` is an alias for `remove`.", + }, + "secret": { + long: "" + + "Description:\n" + + " Run a secret manager's CLI, passing any extra arguments to the secret\n" + + " manager's CLI. This is primarily for verifying chezmoi's integration with your\n" + + " secret manager. Normally you would use template functions to retrieve secrets.\n" + + " Note that if you want to pass flags to the secret manager's CLI you will need\n" + + " to separate them with `--` to prevent chezmoi from interpreting them.\n" + + "\n" + + " To get a full list of available commands run:\n" + + "\n" + + " chezmoi secret help", + example: "" + + " chezmoi secret bitwarden list items\n" + + " chezmoi secret keyring set --service service --user user\n" + + " chezmoi secret keyring get --service service --user user\n" + + " chezmoi secret lastpass ls\n" + + " chezmoi secret lastpass -- show --format=json id\n" + + " chezmoi secret onepassword list items\n" + + " chezmoi secret onepassword get item id\n" + + " chezmoi secret pass show id\n" + + " chezmoi secret vault -- kv get -format=json id", + }, + "source": { + long: "" + + "Description:\n" + + " Execute the source version control system in the source directory with *args*.\n" + + " Note that any flags for the source version control system must be separated\n" + + " with a `--` to stop chezmoi from reading them.", + example: "" + + " chezmoi source init\n" + + " chezmoi source add .\n" + + " chezmoi source commit -- -m \"Initial commit\"", + }, + "source-path": { + long: "" + + "Description:\n" + + " Print the path to each target's source state. If no targets are specified then\n" + + " print the source directory.\n" + + "\n" + + " `source-path` examples\n" + + "\n" + + " chezmoi source-path\n" + + " chezmoi source-path ~/.bashrc", + }, + "unmanage": { + long: "" + + "Description:\n" + + " `unmanage` is an alias for `forget` for symmetry with `manage`.", + }, + "unmanaged": { + long: "" + + "Description:\n" + + " List all unmanaged files in the destination directory.", + example: "" + + " chezmoi unmanaged", + }, + "update": { + long: "" + + "Description:\n" + + " Pull changes from the source VCS and apply any changes.", + example: "" + + " chezmoi update", + }, + "upgrade": { + long: "" + + "Description:\n" + + " Upgrade chezmoi by downloading and installing the latest released version.\n" + + " This will call the GitHub API to determine if there is a new version of\n" + + " chezmoi available, and if so, download and attempt to install it in the same\n" + + " way as chezmoi was previously installed.\n" + + "\n" + + " If chezmoi was installed with a package manager (`dpkg` or `rpm`) then\n" + + " `upgrade` will download a new package and install it, using `sudo` if it is\n" + + " installed. Otherwise, chezmoi will download the latest executable and replace\n" + + " the existing executable with the new version.\n" + + "\n" + + " If the `CHEZMOI_GITHUB_API_TOKEN` environment variable is set, then its value\n" + + " will be used to authenticate requests to the GitHub API, otherwise\n" + + " unauthenticated requests are used which are subject to stricter rate limiting\n" + + " https://developer.github.com/v3/#rate-limiting. Unauthenticated requests should\n" + + " be sufficient for most cases.", + example: "" + + " chezmoi upgrade", + }, + "verify": { + long: "" + + "Description:\n" + + " Verify that all *targets* match their target state. chezmoi exits with code 0\n" + + " (success) if all targets match their target state, or 1 (failure) otherwise.\n" + + " If no targets are specified then all targets are checked.", + example: "" + + " chezmoi verify\n" + + " chezmoi verify ~/.bashrc", + }, +} diff --git a/v2/cmd/root.go b/v2/cmd/root.go index 1439075edea4..b3b8eb17c3b0 100644 --- a/v2/cmd/root.go +++ b/v2/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "errors" + "fmt" "strings" "github.com/coreos/go-semver/semver" @@ -62,3 +63,25 @@ func Execute() error { return rootCmd.Execute() } + +func getExample(command string) string { + return helps[command].example +} + +func markRemainingZshCompPositionalArgumentsAsFiles(cmd *cobra.Command, from int) { + // As far as I can tell, there is no way to mark all remaining positional + // arguments as files. Marking the first eight positional arguments as files + // should be enough for everybody. + // FIXME mark all remaining positional arguments as files + for i := 0; i < 8; i++ { + panicOnError(cmd.MarkZshCompPositionalArgumentFile(from + i)) + } +} + +func mustGetLongHelp(command string) string { + help, ok := helps[command] + if !ok { + panic(fmt.Sprintf("no long help for %s", command)) + } + return help.long +} diff --git a/v2/cmd/templates.gen.go b/v2/cmd/templates.gen.go new file mode 100644 index 000000000000..f93f8fe9455d --- /dev/null +++ b/v2/cmd/templates.gen.go @@ -0,0 +1,31 @@ +// Code generated by github.com/twpayne/chezmoi/internal/generate-assets. DO NOT EDIT. + +package cmd + +func init() { + assets["assets/templates/COMMIT_MESSAGE.tmpl"] = []byte("" + + "{{- /* FIXME replace 46 with '.' when https://github.com/golang/go/issues/34483 is fixed */ -}}\n" + + "{{- /* FIXME generate commit summary */ -}}\n" + + "\n" + + "{{- range .Ordinary -}}\n" + + "{{ if and (eq .X 'A') (eq .Y 46) -}}Add {{ .Path }}\n" + + "{{ else if and (eq .X 'D') (eq .Y 46) -}}Remove {{ .Path }}\n" + + "{{ else if and (eq .X 'M') (eq .Y 46) -}}Update {{ .Path }}\n" + + "{{ else }}{{with (printf \"unsupported XY: %q\" (printf \"%c%c\" .X .Y)) }}{{ fail . }}{{ end }}\n" + + "{{ end }}\n" + + "{{- end -}}\n" + + "\n" + + "{{- range .RenamedOrCopied -}}\n" + + "{{ if and (eq .X 'R') (eq .Y 46) }}Rename {{ .OrigPath }} to {{ .Path }}\n" + + "{{ else }}{{with (printf \"unsupported XY: %q\" (printf \"%c%c\" .X .Y)) }}{{ fail . }}{{ end }}\n" + + "{{ end }}\n" + + "{{- end -}}\n" + + "\n" + + "{{- range .Unmerged -}}\n" + + "{{ fail \"unmerged files\" }}\n" + + "{{- end -}}\n" + + "\n" + + "{{- range .Untracked -}}\n" + + "{{ fail \"untracked files\" }}\n" + + "{{- end -}}\n") +} diff --git a/v2/main.go b/v2/main.go index 5e814649fb73..2b6be64fbec5 100644 --- a/v2/main.go +++ b/v2/main.go @@ -1,5 +1,5 @@ -//go:generate go run ../internal/generate-assets -o cmd/docs.gen.go -tags=!noembeddocs ../docs/CHANGES.md ../docs/CONTRIBUTING.md ../docs/FAQ.md ../docs/HOWTO.md ../docs/INSTALL.md ../docs/MEDIA.md ../docs/QUICKSTART.md ../docs/REFERENCE.md -//go:generate go run ../internal/generate-assets -o cmd/templates.gen.go ../assets/templates/COMMIT_MESSAGE.tmpl +//go:generate go run ../internal/generate-assets -o cmd/docs.gen.go -tags=!noembeddocs -trimprefix=../ ../docs/CHANGES.md ../docs/CONTRIBUTING.md ../docs/FAQ.md ../docs/HOWTO.md ../docs/INSTALL.md ../docs/MEDIA.md ../docs/QUICKSTART.md ../docs/REFERENCE.md +//go:generate go run ../internal/generate-assets -o cmd/templates.gen.go -trimprefix=../ ../assets/templates/COMMIT_MESSAGE.tmpl //go:generate go run ../internal/generate-helps -o cmd/helps.gen.go -i ../docs/REFERENCE.md package main From cb64d07f38fb0093c602707766ad4520590b887f Mon Sep 17 00:00:00 2001 From: Tom Payne Date: Sun, 10 May 2020 19:05:15 +0100 Subject: [PATCH 4/4] snapshot --- internal/generate-assets/main.go | 7 +- v2/cmd/config.go | 104 +++++++++-- v2/cmd/data.go | 43 +++++ v2/internal/chezmoi/chezmoi.go | 2 +- v2/internal/chezmoi/datasystem_test.go | 8 +- v2/internal/chezmoi/encryptiontool_test.go | 2 +- v2/internal/chezmoi/gpgencryptiontool.go | 2 +- v2/internal/chezmoi/gpgencryptiontool_test.go | 2 +- .../chezmoi/jsonserializationformat.go | 4 + v2/internal/chezmoi/realsystem.go | 6 +- v2/internal/chezmoi/serializationformat.go | 3 + .../chezmoi/serializationformat_test.go | 13 ++ v2/internal/chezmoi/targetstateentry.go | 2 +- .../chezmoi/tomlserializationformat.go | 4 + .../chezmoi/yamlserializationformat.go | 4 + v2/main_test.go | 172 ++++++++++++++++++ v2/testdata/scripts/data.txt | 11 ++ 17 files changed, 355 insertions(+), 34 deletions(-) create mode 100644 v2/cmd/data.go create mode 100644 v2/internal/chezmoi/serializationformat_test.go create mode 100644 v2/main_test.go create mode 100644 v2/testdata/scripts/data.txt diff --git a/internal/generate-assets/main.go b/internal/generate-assets/main.go index eec3d68a67c9..ff2deea0a81a 100644 --- a/internal/generate-assets/main.go +++ b/internal/generate-assets/main.go @@ -28,8 +28,9 @@ func init() { {{- end }} }`)) - output = flag.String("o", "/dev/stdout", "output") - tags = flag.String("tags", "", "tags") + output = flag.String("o", "/dev/stdout", "output") + trimPrefix = flag.String("trimprefix", "", "trim prefix") + tags = flag.String("tags", "", "tags") ) func printMultiLineString(s []byte) string { @@ -49,7 +50,7 @@ func run() error { assets := make(map[string][]byte) for _, arg := range flag.Args() { var err error - assets[arg], err = ioutil.ReadFile(arg) + assets[strings.TrimPrefix(arg, *trimPrefix)], err = ioutil.ReadFile(arg) if err != nil { return err } diff --git a/v2/cmd/config.go b/v2/cmd/config.go index 76273a2c86c6..923aa2f0b274 100644 --- a/v2/cmd/config.go +++ b/v2/cmd/config.go @@ -6,8 +6,10 @@ import ( "io" "os" "os/user" + "path/filepath" "regexp" "runtime" + "sort" "strings" "text/template" "unicode" @@ -35,22 +37,26 @@ type templateConfig struct { // A Config represents a configuration. type Config struct { - configFile string - err error - fs vfs.FS - system chezmoi.System - SourceDir string - DestDir string - Umask permValue - DryRun bool - Follow bool - Remove bool - Verbose bool - Color string - Debug bool - SourceVCS sourceVCSConfig - Data map[string]interface{} - Template templateConfig + configFile string + err error + fs vfs.FS + system chezmoi.System + SourceDir string + DestDir string + Umask permValue + DryRun bool + Follow bool + Remove bool + Verbose bool + Color string + Output string // FIXME + Debug bool + SourceVCS sourceVCSConfig + Data map[string]interface{} + Template templateConfig + + data dataCmdConfig + scriptStateBucket []byte Stdin io.Reader Stdout io.Writer @@ -87,6 +93,9 @@ func newConfig(options ...configOption) *Config { funcs: sprig.TxtFuncMap(), Options: chezmoi.DefaultTemplateOptions, }, + data: dataCmdConfig{ + Format: "json", + }, scriptStateBucket: []byte("script"), Stdin: os.Stdin, Stdout: os.Stdout, @@ -116,6 +125,40 @@ func (c *Config) ensureNoError(cmd *cobra.Command, args []string) error { return nil } +func (c *Config) ensureSourceDirectory() error { + info, err := c.fs.Stat(c.SourceDir) + switch { + case err == nil && info.IsDir(): + if chezmoi.POSIXFileModes && info.Mode()&0o77 != 0 { + return c.system.Chmod(c.SourceDir, 0o700&^os.FileMode(c.Umask)) + } + return nil + case os.IsNotExist(err): + if err := vfs.MkdirAll(c.system, filepath.Dir(c.SourceDir), 0o777&^os.FileMode(c.Umask)); err != nil { + return err + } + return c.system.Mkdir(c.SourceDir, 0o700&^os.FileMode(c.Umask)) + case err == nil: + return fmt.Errorf("%s: not a directory", c.SourceDir) + default: + return err + } +} + +func (c *Config) getData() (map[string]interface{}, error) { + defaultData, err := c.getDefaultData() + if err != nil { + return nil, err + } + data := map[string]interface{}{ + "chezmoi": defaultData, + } + for key, value := range c.Data { + data[key] = value + } + return data, nil +} + func (c *Config) getDefaultData() (map[string]interface{}, error) { data := map[string]interface{}{ "arch": runtime.GOARCH, @@ -177,7 +220,7 @@ func (c *Config) getDefaultData() (map[string]interface{}, error) { return data, nil } -func (c *Config) getEditor() (string, []string) { +func getEditor() (string, []string) { editor := os.Getenv("VISUAL") if editor == "" { editor = os.Getenv("EDITOR") @@ -189,6 +232,14 @@ func (c *Config) getEditor() (string, []string) { return components[0], components[1:] } +func getSerializationFormat(name string) (chezmoi.SerializationFormat, error) { + serializationFormat, ok := chezmoi.SerializationFormats[strings.ToLower(name)] + if !ok { + return nil, fmt.Errorf("unknown serialization format: %s", name) + } + return serializationFormat, nil +} + // isWellKnownAbbreviation returns true if word is a well known abbreviation. func isWellKnownAbbreviation(word string) bool { _, ok := wellKnownAbbreviations[word] @@ -201,6 +252,25 @@ func panicOnError(err error) { } } +func serializationFormatNamesStr() string { + names := make([]string, 0, len(chezmoi.SerializationFormats)) + for name := range chezmoi.SerializationFormats { + names = append(names, strings.ToLower(name)) + } + sort.Strings(names) + switch len(names) { + case 0: + return "" + case 1: + return names[0] + case 2: + return names[0] + " or " + names[1] + default: + names[len(names)-1] = "or " + names[len(names)-1] + return strings.Join(names, ", ") + } +} + // titilize returns s, titilized. func titilize(s string) string { if s == "" { diff --git a/v2/cmd/data.go b/v2/cmd/data.go new file mode 100644 index 000000000000..1ed21209a14c --- /dev/null +++ b/v2/cmd/data.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +type dataCmdConfig struct { + Format string +} + +var dataCmd = &cobra.Command{ + Use: "data", + Args: cobra.NoArgs, + Short: "Print the template data", + Long: mustGetLongHelp("data"), + Example: getExample("data"), + PreRunE: config.ensureNoError, + RunE: config.runDataCmd, +} + +func init() { + rootCmd.AddCommand(dataCmd) + + persistentFlags := dataCmd.PersistentFlags() + persistentFlags.StringVarP(&config.data.Format, "format", "f", config.data.Format, "format ("+serializationFormatNamesStr()+")") +} + +func (c *Config) runDataCmd(cmd *cobra.Command, args []string) error { + serializationFormat, err := getSerializationFormat(c.data.Format) + if err != nil { + return err + } + data, err := c.getData() + if err != nil { + return err + } + serializedData, err := serializationFormat.Serialize(data) + if err != nil { + return err + } + _, err = c.Stdout.Write(serializedData) + return err +} diff --git a/v2/internal/chezmoi/chezmoi.go b/v2/internal/chezmoi/chezmoi.go index 9996a5a74928..ea820e009887 100644 --- a/v2/internal/chezmoi/chezmoi.go +++ b/v2/internal/chezmoi/chezmoi.go @@ -8,7 +8,7 @@ import ( // Configuration constants. const ( - posixFileModes = runtime.GOOS != "windows" + POSIXFileModes = runtime.GOOS != "windows" pathSeparator = '/' pathSeparatorStr = string(pathSeparator) ignorePrefix = "." diff --git a/v2/internal/chezmoi/datasystem_test.go b/v2/internal/chezmoi/datasystem_test.go index 6642a8aa51ca..7fd23beb0347 100644 --- a/v2/internal/chezmoi/datasystem_test.go +++ b/v2/internal/chezmoi/datasystem_test.go @@ -65,12 +65,8 @@ func TestDataSystem(t *testing.T) { actualData := dataSystem.Data() assert.Equal(t, expectedData, actualData) - for _, serializationFormat := range []SerializationFormat{ - JSONSerializationFormat, - TOMLSerializationFormat, - YAMLSerializationFormat, - } { - t.Run(serializationFormat.Name(), func(t *testing.T) { + for name, serializationFormat := range SerializationFormats { + t.Run(name, func(t *testing.T) { expectedSerializedData, err := serializationFormat.Serialize(expectedData) require.NoError(t, err) actualSerializedData, err := serializationFormat.Serialize(actualData) diff --git a/v2/internal/chezmoi/encryptiontool_test.go b/v2/internal/chezmoi/encryptiontool_test.go index 53069b2997ce..620bda1ef118 100644 --- a/v2/internal/chezmoi/encryptiontool_test.go +++ b/v2/internal/chezmoi/encryptiontool_test.go @@ -120,7 +120,7 @@ func testEncryptionToolEncryptFile(t *testing.T, et EncryptionTool) { defer func() { assert.NoError(t, os.RemoveAll(tempFile.Name())) }() - if posixFileModes { + if POSIXFileModes { require.NoError(t, tempFile.Chmod(0o600)) } _, err = tempFile.Write(expectedPlaintext) diff --git a/v2/internal/chezmoi/gpgencryptiontool.go b/v2/internal/chezmoi/gpgencryptiontool.go index 94f4be69bdd8..c851e60a2b28 100644 --- a/v2/internal/chezmoi/gpgencryptiontool.go +++ b/v2/internal/chezmoi/gpgencryptiontool.go @@ -77,7 +77,7 @@ func (t *GPGEncryptionTool) Encrypt(plaintext []byte) (ciphertext []byte, err er err = multierr.Append(err, os.RemoveAll(tempFile.Name())) }() - if posixFileModes { + if POSIXFileModes { if err = tempFile.Chmod(0o600); err != nil { return } diff --git a/v2/internal/chezmoi/gpgencryptiontool_test.go b/v2/internal/chezmoi/gpgencryptiontool_test.go index 15ab45d21718..7afb3f6b8ee2 100644 --- a/v2/internal/chezmoi/gpgencryptiontool_test.go +++ b/v2/internal/chezmoi/gpgencryptiontool_test.go @@ -23,7 +23,7 @@ func TestGPGEncryptionTool(t *testing.T) { assert.NoError(t, os.RemoveAll(tempDir)) }() - if posixFileModes { + if POSIXFileModes { require.NoError(t, os.Chmod(tempDir, 0o700)) } diff --git a/v2/internal/chezmoi/jsonserializationformat.go b/v2/internal/chezmoi/jsonserializationformat.go index 823969133bfd..43b6cddf4170 100644 --- a/v2/internal/chezmoi/jsonserializationformat.go +++ b/v2/internal/chezmoi/jsonserializationformat.go @@ -31,3 +31,7 @@ func (jsonSerializationFormat) Deserialize(data []byte) (interface{}, error) { } return result, nil } + +func init() { + SerializationFormats[JSONSerializationFormat.Name()] = JSONSerializationFormat +} diff --git a/v2/internal/chezmoi/realsystem.go b/v2/internal/chezmoi/realsystem.go index f451e18c3235..d80df34d42e8 100644 --- a/v2/internal/chezmoi/realsystem.go +++ b/v2/internal/chezmoi/realsystem.go @@ -33,7 +33,7 @@ func NewRealSystem(fs vfs.FS, persistentState PersistentState) *RealSystem { // Chmod implements System.Glob. func (s *RealSystem) Chmod(name string, mode os.FileMode) error { - if !posixFileModes { + if !POSIXFileModes { return nil } return s.FS.Chmod(name, mode) @@ -78,7 +78,7 @@ func (s *RealSystem) RunScript(scriptname string, data []byte) (err error) { // Make the script private before writing it in case it contains any // secrets. - if posixFileModes { + if POSIXFileModes { if err = f.Chmod(0o700); err != nil { return } @@ -129,7 +129,7 @@ func WriteFile(fs vfs.FS, filename string, data []byte, perm os.FileMode) (err e // Set permissions after truncation but before writing any data, in case the // file contained private data before, but before writing the new contents, // in case the contents contain private data after. - if posixFileModes { + if POSIXFileModes { if err = f.Chmod(perm); err != nil { return } diff --git a/v2/internal/chezmoi/serializationformat.go b/v2/internal/chezmoi/serializationformat.go index 14c7ffe15207..56d8eedd94c8 100644 --- a/v2/internal/chezmoi/serializationformat.go +++ b/v2/internal/chezmoi/serializationformat.go @@ -6,3 +6,6 @@ type SerializationFormat interface { Name() string Serialize(data interface{}) ([]byte, error) } + +// SerializationFormats is a map of all SerializationFormats by name. +var SerializationFormats = make(map[string]SerializationFormat) diff --git a/v2/internal/chezmoi/serializationformat_test.go b/v2/internal/chezmoi/serializationformat_test.go new file mode 100644 index 000000000000..06e20dd128a0 --- /dev/null +++ b/v2/internal/chezmoi/serializationformat_test.go @@ -0,0 +1,13 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSerializationFormats(t *testing.T) { + assert.Contains(t, SerializationFormats, "json") + assert.Contains(t, SerializationFormats, "toml") + assert.Contains(t, SerializationFormats, "yaml") +} diff --git a/v2/internal/chezmoi/targetstateentry.go b/v2/internal/chezmoi/targetstateentry.go index ecdaf9f3e4a6..59255b28f241 100644 --- a/v2/internal/chezmoi/targetstateentry.go +++ b/v2/internal/chezmoi/targetstateentry.go @@ -143,7 +143,7 @@ func (t *TargetStateFile) Equal(destStateEntry DestStateEntry) (bool, error) { log.Printf("other is a %T, not a *DestStateFile\n", destStateEntry) return false, nil } - if posixFileModes && destStateFile.perm != t.perm { + if POSIXFileModes && destStateFile.perm != t.perm { log.Printf("other has perm %o, want %o", destStateFile.perm, t.perm) return false, nil } diff --git a/v2/internal/chezmoi/tomlserializationformat.go b/v2/internal/chezmoi/tomlserializationformat.go index 75e2a2237925..2918450a5073 100644 --- a/v2/internal/chezmoi/tomlserializationformat.go +++ b/v2/internal/chezmoi/tomlserializationformat.go @@ -22,3 +22,7 @@ func (tomlSerializationFormat) Deserialize(data []byte) (interface{}, error) { } return result, nil } + +func init() { + SerializationFormats[TOMLSerializationFormat.Name()] = TOMLSerializationFormat +} diff --git a/v2/internal/chezmoi/yamlserializationformat.go b/v2/internal/chezmoi/yamlserializationformat.go index 1c2dee7d7283..84ba6626cba2 100644 --- a/v2/internal/chezmoi/yamlserializationformat.go +++ b/v2/internal/chezmoi/yamlserializationformat.go @@ -22,3 +22,7 @@ func (yamlSerializationFormat) Deserialize(data []byte) (interface{}, error) { } return result, nil } + +func init() { + SerializationFormats[YAMLSerializationFormat.Name()] = YAMLSerializationFormat +} diff --git a/v2/main_test.go b/v2/main_test.go new file mode 100644 index 000000000000..effae250f128 --- /dev/null +++ b/v2/main_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/rogpeppe/go-internal/testscript" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" +) + +//nolint:interfacer +func TestMain(m *testing.M) { + os.Exit(testscript.RunMain(m, map[string]func() int{ + "chezmoi": testRun, + })) +} + +func TestChezmoi(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration tests in short mode") + } + testscript.Run(t, testscript.Params{ + Dir: filepath.Join("testdata", "scripts"), + Cmds: map[string]func(*testscript.TestScript, bool, []string){ + "chhome": chHome, + "edit": edit, + }, + Condition: func(cond string) (bool, error) { + switch cond { + case "windows": + return runtime.GOOS == "windows", nil + default: + return false, fmt.Errorf("unknown condition: %s", cond) + } + }, + Setup: setup, + }) +} + +func testRun() int { + if err := run(); err != nil { + if s := err.Error(); s != "" { + fmt.Printf("chezmoi: %s\n", s) + } + return 1 + } + return 0 +} + +// chHome changes the home directory to its argument, creating the directory if +// it does not already exists. It updates the HOME environment variable, and, if +// running on Windows, USERPROFILE too. +func chHome(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported ! chhome") + } + if len(args) != 1 { + ts.Fatalf("usage: chhome dir") + } + homeDir := args[0] + if !filepath.IsAbs(homeDir) { + homeDir = filepath.Join(ts.Getenv("WORK"), homeDir) + } + ts.Check(os.MkdirAll(homeDir, 0o777)) + ts.Setenv("HOME", homeDir) + if runtime.GOOS == "windows" { + ts.Setenv("USERPROFILE", homeDir) + } +} + +// edit edits all of its arguments by appending "# edited\n" to them. +func edit(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported ! edit") + } + for _, arg := range args { + filename := ts.MkAbs(arg) + data, err := ioutil.ReadFile(filename) + if err != nil { + ts.Fatalf("edit: %v", err) + } + data = append(data, []byte("# edited\n")...) + if err := ioutil.WriteFile(filename, data, 0o666); err != nil { + ts.Fatalf("edit: %v", err) + } + } +} + +func setup(env *testscript.Env) error { + var ( + binDir = filepath.Join(env.WorkDir, "bin") + homeDir = filepath.Join(env.WorkDir, "home", "user") + chezmoiConfigDir = filepath.Join(homeDir, ".config", "chezmoi") + chezmoiSourceDir = filepath.Join(homeDir, ".local", "share", "chezmoi") + ) + + env.Setenv("HOME", homeDir) + env.Setenv("PATH", prependDirToPath(binDir, env.Getenv("PATH"))) + env.Setenv("CHEZMOICONFIGDIR", chezmoiConfigDir) + env.Setenv("CHEZMOISOURCEDIR", chezmoiSourceDir) + switch runtime.GOOS { + case "windows": + env.Setenv("EDITOR", filepath.Join(binDir, "editor.cmd")) + env.Setenv("USERPROFILE", homeDir) + // There is not currently a convenient way to override the shell on + // Windows. + default: + env.Setenv("EDITOR", filepath.Join(binDir, "editor")) + env.Setenv("SHELL", filepath.Join(binDir, "shell")) + } + + root := map[string]interface{}{ + "/home/user": map[string]interface{}{ + // .gitconfig is populated with a user and email to avoid warnings + // from git. + ".gitconfig": strings.Join([]string{ + `[user]`, + ` name = Username`, + ` email = user@home.org`, + }, "\n"), + }, + } + + switch runtime.GOOS { + case "windows": + root["/bin"] = map[string]interface{}{ + // editor.cmd a non-interactive script that appends "# edited\n" to + // the end of each file. + "editor.cmd": &vfst.File{ + Perm: 0o755, + Contents: []byte(`@for %%x in (%*) do echo # edited>>%%x`), + }, + } + default: + root["/bin"] = map[string]interface{}{ + // editor a non-interactive script that appends "# edited\n" to the + // end of each file. + "editor": &vfst.File{ + Perm: 0o755, + Contents: []byte(strings.Join([]string{ + `#!/bin/sh`, + ``, + `for filename in $*; do`, + ` echo "# edited" >> $filename`, + `done`, + }, "\n")), + }, + // shell is a non-interactive script that appends the directory in + // which it was launched to $WORK/shell.log. + "shell": &vfst.File{ + Perm: 0o755, + Contents: []byte(strings.Join([]string{ + `#!/bin/sh`, + ``, + `echo $PWD >> ` + filepath.Join(env.WorkDir, "shell.log"), + }, "\n")), + }, + } + } + + return vfst.NewBuilder().Build(vfs.NewPathFS(vfs.HostOSFS, env.WorkDir), root) +} + +func prependDirToPath(dir, path string) string { + return strings.Join(append([]string{dir}, filepath.SplitList(path)...), string(os.PathListSeparator)) +} diff --git a/v2/testdata/scripts/data.txt b/v2/testdata/scripts/data.txt new file mode 100644 index 000000000000..ce513fba7909 --- /dev/null +++ b/v2/testdata/scripts/data.txt @@ -0,0 +1,11 @@ +chezmoi data +stdout '"chezmoi":' + +chezmoi data --format=json +stdout '"chezmoi":' + +chezmoi data --format=toml +stdout '[chezmoi]' + +chezmoi data --format=yaml +stdout 'chezmoi:'