pull/32657/merge
jansure 2 weeks ago committed by GitHub
commit b449ed0fa3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -52,6 +52,25 @@ require (
sigs.k8s.io/yaml v1.6.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
require (
dario.cat/mergo v1.0.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
@ -78,6 +97,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-git/go-git/v5 v5.19.1
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.4 // indirect
@ -142,6 +162,7 @@ require (
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
gitlab.com/gitlab-org/api/client-go/v2 v2.51.0
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 // indirect
@ -168,7 +189,7 @@ require (
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect

@ -22,12 +22,19 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/Masterminds/vcs v1.13.3 h1:IIA2aBdXvfbIM+yl/eTnL4hb1XwdpvuQLglAix1gweE=
github.com/Masterminds/vcs v1.13.3/go.mod h1:TiE7xuEjl1N4j016moRd6vezp6e6Lz23gypeXfzXeW8=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@ -81,8 +88,12 @@ github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQ
github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
@ -101,8 +112,18 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
@ -161,6 +182,8 @@ github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5
github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo=
github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -171,8 +194,11 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
@ -186,6 +212,12 @@ github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw=
github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU=
github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4=
@ -196,6 +228,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
@ -203,13 +237,20 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@ -267,7 +308,11 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -313,9 +358,12 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@ -329,6 +377,7 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
@ -339,11 +388,15 @@ github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZ
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
gitlab.com/gitlab-org/api/client-go/v2 v2.51.0 h1:jP3bsuS3WpiEqhxhG58GJtqCyOnjyACQ8u8oyUg7ru8=
gitlab.com/gitlab-org/api/client-go/v2 v2.51.0/go.mod h1:P0sRPwCAGIek6HIU0JH5W9Ic5+z3eWvCWwXeg7Pwr7o=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
@ -408,6 +461,7 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
@ -423,6 +477,7 @@ golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
@ -449,9 +504,12 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -472,6 +530,7 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
@ -502,13 +561,17 @@ google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fw
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

@ -0,0 +1,173 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"context"
"errors"
"fmt"
"net/url"
"slices"
"strings"
)
const (
APIProviderAuto = "auto"
APIProviderGitLab = "gitlab"
APITokenAuto = "auto"
APITokenPrivate = "private"
APITokenOAuth = "oauth"
APITokenJob = "job"
)
// APIOptions configures direct repository-provider API access.
type APIOptions struct {
Provider string
BaseURL string
Project string
TokenType string
}
// ValidAPIProviders returns the repository APIs supported by TransportAPI.
func ValidAPIProviders() []string {
return []string{APIProviderAuto, APIProviderGitLab}
}
// ValidAPITokenTypes returns the authentication token types supported by API
// backends. Individual providers may support only a subset.
func ValidAPITokenTypes() []string {
return []string{APITokenAuto, APITokenPrivate, APITokenOAuth, APITokenJob}
}
func validateAPIOptions(opts RepositoryOptions) error {
provider, err := resolveAPIProvider(opts.API)
if err != nil {
return err
}
if _, err := resolveAPITokenType(opts.API.TokenType, opts.Auth.Type); err != nil {
return err
}
if opts.Auth.Token == "" {
return errors.New("API transport requires --token, --token-file, or HELM_GIT_TOKEN")
}
switch opts.Auth.Type {
case "", AuthAuto, AuthToken, AuthBearer:
default:
return fmt.Errorf("authentication mode %q is not supported by the %s API transport", opts.Auth.Type, provider)
}
if opts.Auth.Password != "" || opts.Auth.SSHPrivateKey != "" || opts.Auth.SSHKeyPassphrase != "" ||
opts.Auth.KnownHostsFile != "" || opts.Auth.InsecureHostKey {
return fmt.Errorf("password and SSH authentication options are not supported by the %s API transport", provider)
}
if !opts.Push && !opts.DryRun {
return errors.New("API transport writes commits remotely and requires --push=true (use --dry-run to validate without writing)")
}
_, _, err = resolveGitLabCoordinates(opts.RepositoryURL, opts.API)
return err
}
func updateViaAPI(ctx context.Context, opts RepositoryOptions, result Result) (Result, error) {
if _, err := resolveAPIProvider(opts.API); err != nil {
return result, err
}
return updateViaGitLabAPI(ctx, opts, result)
}
func resolveAPIProvider(apiOpts APIOptions) (string, error) {
provider := apiOpts.Provider
if provider == "" {
provider = APIProviderAuto
}
if provider == APIProviderAuto || provider == APIProviderGitLab {
return APIProviderGitLab, nil
}
return "", fmt.Errorf("unknown API provider %q (supported: %s)", provider, strings.Join(ValidAPIProviders(), ", "))
}
func resolveAPITokenType(tokenType, authType string) (string, error) {
if tokenType == "" || tokenType == APITokenAuto {
if authType == AuthBearer {
return APITokenOAuth, nil
}
return APITokenPrivate, nil
}
if slices.Contains(ValidAPITokenTypes(), tokenType) {
return tokenType, nil
}
return "", fmt.Errorf("unknown API token type %q (supported: %s)", tokenType, strings.Join(ValidAPITokenTypes(), ", "))
}
func resolveAPICoordinates(repositoryURL string, apiOpts APIOptions, apiPath, providerName string) (string, string, error) {
inferredHost, inferredProject, inferredScheme, err := inferRepositoryCoordinates(repositoryURL)
if err != nil && (apiOpts.BaseURL == "" || apiOpts.Project == "") {
return "", "", err
}
baseURL := apiOpts.BaseURL
if baseURL == "" {
baseURL = fmt.Sprintf("%s://%s/%s", inferredScheme, inferredHost, strings.Trim(apiPath, "/"))
}
project := apiOpts.Project
if project == "" {
project = inferredProject
}
if project == "" {
return "", "", fmt.Errorf("%s API project is empty (set --api-project)", providerName)
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", "", fmt.Errorf("invalid %s API base URL %q", providerName, baseURL)
}
return strings.TrimRight(baseURL, "/") + "/", project, nil
}
func inferRepositoryCoordinates(repositoryURL string) (host, project, scheme string, err error) {
if strings.Contains(repositoryURL, "://") {
parsed, parseErr := url.Parse(repositoryURL)
if parseErr != nil {
return "", "", "", fmt.Errorf("parse repository URL for API access: %w", parseErr)
}
host = parsed.Host
project = strings.Trim(parsed.Path, "/")
switch parsed.Scheme {
case "http", "https":
scheme = parsed.Scheme
case "ssh", "git+ssh":
scheme = "https"
default:
return "", "", "", fmt.Errorf("cannot infer API URL from repository scheme %q", parsed.Scheme)
}
} else {
at := strings.IndexByte(repositoryURL, '@')
colon := strings.IndexByte(repositoryURL, ':')
if at <= 0 || colon <= at+1 {
return "", "", "", errors.New("cannot infer API coordinates from repository URL (set --api-base-url, --api-project, and --api-provider)")
}
scheme = "https"
host = repositoryURL[at+1 : colon]
project = repositoryURL[colon+1:]
}
project = strings.TrimSuffix(strings.Trim(project, "/"), ".git")
if host == "" || project == "" {
return "", "", "", errors.New("repository URL does not contain a host and project path")
}
return host, project, scheme, nil
}

@ -0,0 +1,212 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"errors"
"fmt"
"os"
"strings"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"golang.org/x/crypto/ssh"
"github.com/go-git/go-git/v5/plumbing/transport"
)
const (
AuthAuto = "auto"
AuthNone = "none"
AuthBasic = "basic"
AuthToken = "token"
AuthBearer = "bearer"
AuthSSHKey = "ssh-key"
AuthSSHAgent = "ssh-agent"
AuthSSHPassword = "ssh-password"
)
// ValidAuthTypes returns the authentication modes understood by BuildAuth.
func ValidAuthTypes() []string {
return []string{
AuthAuto,
AuthNone,
AuthBasic,
AuthToken,
AuthBearer,
AuthSSHKey,
AuthSSHAgent,
AuthSSHPassword,
}
}
// AuthOptions contains credentials for Git HTTP(S) and SSH transports.
type AuthOptions struct {
Type string
Username string
Password string
Token string
SSHPrivateKey string
SSHKeyPassphrase string
KnownHostsFile string
InsecureHostKey bool
SSHAgentSocketIsSet bool
}
// BuildAuth constructs a go-git authentication method. Automatic mode chooses
// an explicit credential first, then an SSH agent for SSH URLs.
func BuildAuth(repositoryURL string, opts AuthOptions) (transport.AuthMethod, error) {
authType := opts.Type
if authType == "" {
authType = AuthAuto
}
if authType == AuthAuto {
switch {
case opts.SSHPrivateKey != "":
authType = AuthSSHKey
case opts.Token != "":
authType = AuthToken
case opts.Password != "" && isSSHURL(repositoryURL):
authType = AuthSSHPassword
case opts.Password != "" || opts.Username != "":
authType = AuthBasic
case isSSHURL(repositoryURL) && opts.SSHAgentSocketIsSet:
authType = AuthSSHAgent
default:
authType = AuthNone
}
}
sshTransport := isSSHURL(repositoryURL)
httpTransport := strings.HasPrefix(repositoryURL, "http://") || strings.HasPrefix(repositoryURL, "https://")
switch authType {
case AuthBasic, AuthToken, AuthBearer:
if !httpTransport {
return nil, fmt.Errorf("authentication mode %q requires an HTTP(S) repository URL", authType)
}
case AuthSSHKey, AuthSSHAgent, AuthSSHPassword:
if !sshTransport {
return nil, fmt.Errorf("authentication mode %q requires an SSH repository URL", authType)
}
}
switch authType {
case AuthNone:
if opts.Username != "" || opts.Password != "" || opts.Token != "" || opts.SSHPrivateKey != "" || opts.SSHKeyPassphrase != "" {
return nil, errors.New("authentication mode \"none\" cannot be combined with credentials")
}
if opts.KnownHostsFile != "" || opts.InsecureHostKey {
return nil, errors.New("SSH host-key options require ssh-key, ssh-agent, or ssh-password authentication")
}
return nil, nil
case AuthBasic:
if opts.Username == "" {
return nil, errors.New("basic authentication requires --username")
}
if opts.Password == "" {
return nil, errors.New("basic authentication requires a password")
}
return &githttp.BasicAuth{Username: opts.Username, Password: opts.Password}, nil
case AuthToken:
if opts.Token == "" {
return nil, errors.New("token authentication requires a token")
}
username := opts.Username
if username == "" {
username = "git"
}
return &githttp.BasicAuth{Username: username, Password: opts.Token}, nil
case AuthBearer:
if opts.Token == "" {
return nil, errors.New("bearer authentication requires a token")
}
return &githttp.TokenAuth{Token: opts.Token}, nil
case AuthSSHKey:
if opts.SSHPrivateKey == "" {
return nil, errors.New("ssh-key authentication requires --ssh-private-key")
}
username := sshUsername(opts.Username)
key, err := gitssh.NewPublicKeysFromFile(username, opts.SSHPrivateKey, opts.SSHKeyPassphrase)
if err != nil {
return nil, fmt.Errorf("load SSH private key: %w", err)
}
if err := configureHostKeyCallback(&key.HostKeyCallbackHelper, opts); err != nil {
return nil, err
}
return key, nil
case AuthSSHAgent:
username := sshUsername(opts.Username)
agent, err := gitssh.NewSSHAgentAuth(username)
if err != nil {
return nil, fmt.Errorf("connect to SSH agent: %w", err)
}
if err := configureHostKeyCallback(&agent.HostKeyCallbackHelper, opts); err != nil {
return nil, err
}
return agent, nil
case AuthSSHPassword:
if opts.Password == "" {
return nil, errors.New("ssh-password authentication requires a password")
}
password := &gitssh.Password{User: sshUsername(opts.Username), Password: opts.Password}
if err := configureHostKeyCallback(&password.HostKeyCallbackHelper, opts); err != nil {
return nil, err
}
return password, nil
default:
return nil, fmt.Errorf("unknown authentication mode %q (supported: %s)", authType, strings.Join(ValidAuthTypes(), ", "))
}
}
func configureHostKeyCallback(helper *gitssh.HostKeyCallbackHelper, opts AuthOptions) error {
if opts.InsecureHostKey && opts.KnownHostsFile != "" {
return errors.New("--insecure-skip-ssh-host-key cannot be combined with --ssh-known-hosts")
}
if opts.InsecureHostKey {
helper.HostKeyCallback = ssh.InsecureIgnoreHostKey()
return nil
}
if opts.KnownHostsFile != "" {
callback, err := gitssh.NewKnownHostsCallback(opts.KnownHostsFile)
if err != nil {
return fmt.Errorf("load SSH known-hosts file: %w", err)
}
helper.HostKeyCallback = callback
}
return nil
}
func isSSHURL(repositoryURL string) bool {
if strings.HasPrefix(repositoryURL, "ssh://") || strings.HasPrefix(repositoryURL, "git+ssh://") {
return true
}
at := strings.IndexByte(repositoryURL, '@')
colon := strings.IndexByte(repositoryURL, ':')
return at > 0 && colon > at+1 && !strings.Contains(repositoryURL[:colon], "://")
}
func sshUsername(username string) string {
if username == "" {
return gitssh.DefaultUsername
}
return username
}
// AgentAvailable reports whether an SSH agent socket is configured.
func AgentAvailable() bool {
return os.Getenv("SSH_AUTH_SOCK") != ""
}

@ -0,0 +1,128 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"testing"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildAuth(t *testing.T) {
tests := []struct {
name string
url string
options AuthOptions
expected any
expectError string
}{
{
name: "none",
url: "https://example.com/repo.git",
options: AuthOptions{Type: AuthNone},
expected: nil,
},
{
name: "basic",
url: "https://example.com/repo.git",
options: AuthOptions{
Type: AuthBasic,
Username: "alice",
Password: "secret",
},
expected: &githttp.BasicAuth{},
},
{
name: "token defaults username",
url: "https://example.com/repo.git",
options: AuthOptions{
Type: AuthToken,
Token: "secret",
},
expected: &githttp.BasicAuth{},
},
{
name: "bearer",
url: "https://example.com/repo.git",
options: AuthOptions{
Type: AuthBearer,
Token: "secret",
},
expected: &githttp.TokenAuth{},
},
{
name: "auto selects token",
url: "https://example.com/repo.git",
options: AuthOptions{
Type: AuthAuto,
Token: "secret",
},
expected: &githttp.BasicAuth{},
},
{
name: "basic requires username",
url: "https://example.com/repo.git",
options: AuthOptions{Type: AuthBasic, Password: "secret"},
expectError: "requires --username",
},
{
name: "unknown mode",
url: "https://example.com/repo.git",
options: AuthOptions{Type: "magic"},
expectError: "unknown authentication mode",
},
{
name: "HTTP authentication rejects SSH URL",
url: "git@example.com:group/repo.git",
options: AuthOptions{Type: AuthToken, Token: "secret"},
expectError: "requires an HTTP(S) repository URL",
},
{
name: "SSH authentication rejects HTTP URL",
url: "https://example.com/repo.git",
options: AuthOptions{Type: AuthSSHAgent},
expectError: "requires an SSH repository URL",
},
{
name: "host-key option requires explicit SSH authentication",
url: "git@example.com:group/repo.git",
options: AuthOptions{Type: AuthNone, InsecureHostKey: true},
expectError: "SSH host-key options require",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := BuildAuth(test.url, test.options)
if test.expectError != "" {
require.ErrorContains(t, err, test.expectError)
return
}
require.NoError(t, err)
if test.expected == nil {
assert.Nil(t, actual)
return
}
assert.IsType(t, test.expected, actual)
if token, ok := actual.(*githttp.BasicAuth); ok && test.name == "token defaults username" {
assert.Equal(t, "git", token.Username)
}
})
}
}

@ -0,0 +1,356 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"strings"
)
// Method identifies how content is applied to the target file.
type Method string
const (
MethodOverwrite Method = "overwrite"
MethodReplace Method = "replace"
MethodInsertBefore Method = "insert-before"
MethodInsertAfter Method = "insert-after"
MethodAppend Method = "append"
MethodPrepend Method = "prepend"
MethodYAMLSet Method = "yaml-set"
MethodYAMLMerge Method = "yaml-merge"
MethodYAMLDelete Method = "yaml-delete"
MethodJSONSet Method = "json-set"
MethodJSONMerge Method = "json-merge"
MethodJSONDelete Method = "json-delete"
MethodJSONPatch Method = "json-patch"
MethodJSONMergePatch Method = "json-merge-patch"
)
// ValidMethods returns the supported file editing methods.
func ValidMethods() []string {
return []string{
string(MethodOverwrite),
string(MethodReplace),
string(MethodInsertBefore),
string(MethodInsertAfter),
string(MethodAppend),
string(MethodPrepend),
string(MethodYAMLSet),
string(MethodYAMLMerge),
string(MethodYAMLDelete),
string(MethodJSONSet),
string(MethodJSONMerge),
string(MethodJSONDelete),
string(MethodJSONPatch),
string(MethodJSONMergePatch),
}
}
// EditOptions describes one deterministic edit to a file.
type EditOptions struct {
Method Method
Content []byte
ContentProvided bool
LiteralMatch string
RegexMatch string
ExpectedMatches int
AllMatches bool
Expand bool
Pointer string
CreatePath bool
}
// ApplyFile applies an edit to targetPath underneath worktreeRoot. It rejects
// paths that escape the worktree, paths inside .git, and symbolic links.
func ApplyFile(worktreeRoot, targetPath string, opts EditOptions, createFile bool) (bool, error) {
targetPath, err := validateTargetPath(targetPath)
if err != nil {
return false, err
}
root, err := os.OpenRoot(worktreeRoot)
if err != nil {
return false, fmt.Errorf("open worktree: %w", err)
}
defer root.Close()
localPath := filepath.FromSlash(targetPath)
if err := rejectSymlinks(root, localPath); err != nil {
return false, err
}
existed := true
original, err := root.ReadFile(localPath)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return false, fmt.Errorf("read target file %q: %w", targetPath, err)
}
if !createFile {
return false, fmt.Errorf("target file %q does not exist (use --create-file to create it)", targetPath)
}
existed = false
original = initialContent(opts.Method)
}
updated, err := Apply(original, opts)
if err != nil {
return false, fmt.Errorf("edit %q: %w", targetPath, err)
}
if existed && bytes.Equal(original, updated) {
return false, nil
}
parent := filepath.Dir(localPath)
if parent != "." {
if err := root.MkdirAll(parent, 0o755); err != nil {
return false, fmt.Errorf("create parent directories for %q: %w", targetPath, err)
}
}
mode := fs.FileMode(0o644)
if info, statErr := root.Stat(localPath); statErr == nil {
if !info.Mode().IsRegular() {
return false, fmt.Errorf("target path %q is not a regular file", targetPath)
}
mode = info.Mode().Perm()
} else if !errors.Is(statErr, fs.ErrNotExist) {
return false, fmt.Errorf("inspect target file %q: %w", targetPath, statErr)
}
if err := root.WriteFile(localPath, updated, mode); err != nil {
return false, fmt.Errorf("write target file %q: %w", targetPath, err)
}
return true, nil
}
func validateTargetPath(targetPath string) (string, error) {
if targetPath == "" {
return "", errors.New("target file is required")
}
if filepath.IsAbs(targetPath) || path.IsAbs(filepath.ToSlash(targetPath)) {
return "", fmt.Errorf("target file %q must be relative to the repository root", targetPath)
}
clean := path.Clean(filepath.ToSlash(targetPath))
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("target file %q escapes the repository root", targetPath)
}
for segment := range strings.SplitSeq(clean, "/") {
if strings.EqualFold(segment, ".git") {
return "", fmt.Errorf("target file %q must not be inside .git", targetPath)
}
}
return clean, nil
}
func rejectSymlinks(root *os.Root, targetPath string) error {
current := ""
for segment := range strings.SplitSeq(filepath.Clean(targetPath), string(filepath.Separator)) {
if current == "" {
current = segment
} else {
current = filepath.Join(current, segment)
}
info, err := root.Lstat(current)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("inspect target path %q: %w", filepath.ToSlash(current), err)
}
if info.Mode()&fs.ModeSymlink != 0 {
return fmt.Errorf("target path %q contains symbolic link %q", filepath.ToSlash(targetPath), filepath.ToSlash(current))
}
}
return nil
}
func initialContent(method Method) []byte {
switch method {
case MethodYAMLSet, MethodYAMLMerge, MethodYAMLDelete:
return []byte("{}\n")
case MethodJSONSet, MethodJSONMerge, MethodJSONDelete, MethodJSONPatch, MethodJSONMergePatch:
return []byte("{}\n")
default:
return nil
}
}
// Apply returns the edited file contents without writing to disk.
func Apply(original []byte, opts EditOptions) ([]byte, error) {
if err := validateEditOptions(opts); err != nil {
return nil, err
}
switch opts.Method {
case MethodOverwrite:
return append([]byte(nil), opts.Content...), nil
case MethodAppend:
return append(append([]byte(nil), original...), opts.Content...), nil
case MethodPrepend:
return append(append([]byte(nil), opts.Content...), original...), nil
case MethodReplace, MethodInsertBefore, MethodInsertAfter:
return applySelectedText(original, opts)
case MethodYAMLSet, MethodYAMLMerge, MethodYAMLDelete:
return applyYAML(original, opts)
case MethodJSONSet, MethodJSONMerge, MethodJSONDelete:
return applyJSON(original, opts)
case MethodJSONPatch, MethodJSONMergePatch:
return applyJSONPatch(original, opts)
default:
return nil, fmt.Errorf("unknown edit method %q", opts.Method)
}
}
func validateEditOptions(opts EditOptions) error {
if !slices.Contains(ValidMethods(), string(opts.Method)) {
return fmt.Errorf("unknown edit method %q (supported: %s)", opts.Method, strings.Join(ValidMethods(), ", "))
}
if opts.ExpectedMatches < 0 {
return errors.New("expected matches must be zero or greater")
}
needsContent := opts.Method != MethodYAMLDelete && opts.Method != MethodJSONDelete
if needsContent && !opts.ContentProvided {
return fmt.Errorf("method %q requires content", opts.Method)
}
if !needsContent && opts.ContentProvided {
return fmt.Errorf("method %q does not accept content", opts.Method)
}
isSelectedText := opts.Method == MethodReplace || opts.Method == MethodInsertBefore || opts.Method == MethodInsertAfter
hasLiteral := opts.LiteralMatch != ""
hasRegex := opts.RegexMatch != ""
if isSelectedText {
if hasLiteral == hasRegex {
return fmt.Errorf("method %q requires exactly one of a non-empty literal match or regular expression", opts.Method)
}
} else if hasLiteral || hasRegex {
return fmt.Errorf("method %q does not accept a text selector", opts.Method)
}
if opts.AllMatches && !isSelectedText {
return fmt.Errorf("method %q does not accept --all", opts.Method)
}
isStructuredPath := opts.Method == MethodYAMLSet || opts.Method == MethodYAMLMerge || opts.Method == MethodYAMLDelete ||
opts.Method == MethodJSONSet || opts.Method == MethodJSONMerge || opts.Method == MethodJSONDelete
if isStructuredPath {
if _, err := parsePointer(opts.Pointer); err != nil {
return err
}
} else if opts.Pointer != "" {
return fmt.Errorf("method %q does not accept a JSON pointer", opts.Method)
}
canCreatePath := opts.Method == MethodYAMLSet || opts.Method == MethodYAMLMerge ||
opts.Method == MethodJSONSet || opts.Method == MethodJSONMerge
if opts.CreatePath && !canCreatePath {
return fmt.Errorf("method %q does not accept --create-path", opts.Method)
}
if opts.Expand && opts.RegexMatch == "" {
return errors.New("capture expansion requires a regular expression selector")
}
return nil
}
func applySelectedText(original []byte, opts EditOptions) ([]byte, error) {
if opts.LiteralMatch != "" {
return applyLiteralSelector(original, opts)
}
return applyRegexSelector(original, opts)
}
func applyLiteralSelector(original []byte, opts EditOptions) ([]byte, error) {
match := []byte(opts.LiteralMatch)
actual := bytes.Count(original, match)
if err := validateMatchCount(actual, opts.ExpectedMatches); err != nil {
return nil, err
}
replacement := opts.Content
switch opts.Method {
case MethodInsertBefore:
replacement = append(append([]byte(nil), opts.Content...), match...)
case MethodInsertAfter:
replacement = append(append([]byte(nil), match...), opts.Content...)
case MethodReplace:
default:
return nil, fmt.Errorf("unsupported literal text method %q", opts.Method)
}
limit := 1
if opts.AllMatches {
limit = -1
}
return bytes.Replace(original, match, replacement, limit), nil
}
func applyRegexSelector(original []byte, opts EditOptions) ([]byte, error) {
expression, err := regexp.Compile(opts.RegexMatch)
if err != nil {
return nil, fmt.Errorf("compile regular expression: %w", err)
}
matches := expression.FindAllSubmatchIndex(original, -1)
if err := validateMatchCount(len(matches), opts.ExpectedMatches); err != nil {
return nil, err
}
if !opts.AllMatches {
matches = matches[:1]
}
var result bytes.Buffer
last := 0
for _, indexes := range matches {
start, end := indexes[0], indexes[1]
result.Write(original[last:start])
if opts.Method == MethodInsertAfter {
result.Write(original[start:end])
}
if opts.Expand {
result.Write(expression.Expand(nil, opts.Content, original, indexes))
} else {
result.Write(opts.Content)
}
if opts.Method == MethodInsertBefore {
result.Write(original[start:end])
}
last = end
}
result.Write(original[last:])
return result.Bytes(), nil
}
func validateMatchCount(actual, expected int) error {
if actual == 0 {
return errors.New("selector did not match the target file")
}
if expected > 0 && actual != expected {
return fmt.Errorf("selector matched %d times; expected %d", actual, expected)
}
return nil
}

@ -0,0 +1,364 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyText(t *testing.T) {
tests := []struct {
name string
original string
options EditOptions
expected string
errorText string
}{
{
name: "overwrite",
original: "old\n",
options: textOptions(MethodOverwrite, "new\n"),
expected: "new\n",
},
{
name: "literal replacement",
original: "replicas: 2\n",
options: EditOptions{
Method: MethodReplace,
Content: []byte("replicas: 3"),
ContentProvided: true,
LiteralMatch: "replicas: 2",
ExpectedMatches: 1,
},
expected: "replicas: 3\n",
},
{
name: "insert after",
original: "first\nlast\n",
options: EditOptions{
Method: MethodInsertAfter,
Content: []byte("middle\n"),
ContentProvided: true,
LiteralMatch: "first\n",
ExpectedMatches: 1,
},
expected: "first\nmiddle\nlast\n",
},
{
name: "regular expression capture expansion",
original: "image: app:v1\n",
options: EditOptions{
Method: MethodReplace,
Content: []byte("${1}v2"),
ContentProvided: true,
RegexMatch: `(image: app:)v1`,
ExpectedMatches: 1,
Expand: true,
},
expected: "image: app:v2\n",
},
{
name: "replace all",
original: "dev dev dev",
options: EditOptions{
Method: MethodReplace,
Content: []byte("prod"),
ContentProvided: true,
LiteralMatch: "dev",
ExpectedMatches: 3,
AllMatches: true,
},
expected: "prod prod prod",
},
{
name: "safe count rejects an ambiguous selector",
original: "dev dev",
options: EditOptions{
Method: MethodReplace,
Content: []byte("prod"),
ContentProvided: true,
LiteralMatch: "dev",
ExpectedMatches: 1,
},
errorText: "selector matched 2 times; expected 1",
},
{
name: "selector must match",
original: "dev",
options: EditOptions{
Method: MethodReplace,
Content: []byte("prod"),
ContentProvided: true,
LiteralMatch: "staging",
ExpectedMatches: 0,
},
errorText: "selector did not match",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := Apply([]byte(test.original), test.options)
if test.errorText != "" {
require.ErrorContains(t, err, test.errorText)
return
}
require.NoError(t, err)
assert.Equal(t, test.expected, string(actual))
})
}
}
func TestApplyYAML(t *testing.T) {
tests := []struct {
name string
original string
options EditOptions
expected string
}{
{
name: "set nested value and preserve comment",
original: `image:
repository: example/app
tag: v1 # deployed version
`,
options: EditOptions{
Method: MethodYAMLSet,
Content: []byte("v2"),
ContentProvided: true,
Pointer: "/image/tag",
},
expected: `image:
repository: example/app
tag: v2 # deployed version
`,
},
{
name: "create mapping path",
original: `image: {}
`,
options: EditOptions{
Method: MethodYAMLSet,
Content: []byte("v2"),
ContentProvided: true,
Pointer: "/image/tag",
CreatePath: true,
},
expected: `image: {tag: v2}
`,
},
{
name: "deep merge",
original: `image:
repository: example/app
resources:
limits:
cpu: 100m
`,
options: EditOptions{
Method: MethodYAMLMerge,
Content: []byte(`limits:
memory: 128Mi
requests:
cpu: 50m
`),
ContentProvided: true,
Pointer: "/resources",
},
expected: `image:
repository: example/app
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 50m
`,
},
{
name: "delete",
original: `image:
repository: example/app
tag: v1
`,
options: EditOptions{
Method: MethodYAMLDelete,
Pointer: "/image/tag",
},
expected: `image:
repository: example/app
`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := Apply([]byte(test.original), test.options)
require.NoError(t, err)
assert.Equal(t, test.expected, string(actual))
})
}
}
func TestApplyYAMLNoSemanticChangePreservesBytes(t *testing.T) {
original := []byte("image: {tag: v1} # keep compact style\n")
actual, err := Apply(original, EditOptions{
Method: MethodYAMLSet,
Content: []byte("v1"),
ContentProvided: true,
Pointer: "/image/tag",
})
require.NoError(t, err)
assert.Equal(t, original, actual)
}
func TestApplyYAMLRejectsDuplicateKeys(t *testing.T) {
_, err := Apply([]byte("image:\n tag: v1\n tag: v2\n"), EditOptions{
Method: MethodYAMLSet,
Content: []byte("v3"),
ContentProvided: true,
Pointer: "/image/tag",
})
require.ErrorContains(t, err, `duplicate YAML mapping key "tag"`)
}
func TestApplyJSON(t *testing.T) {
tests := []struct {
name string
original string
options EditOptions
expected string
}{
{
name: "set array value",
original: `{"containers":[{"image":"app:v1"}]}`,
options: EditOptions{
Method: MethodJSONSet,
Content: []byte(`"app:v2"`),
ContentProvided: true,
Pointer: "/containers/0/image",
},
expected: "{\n \"containers\": [\n {\n \"image\": \"app:v2\"\n }\n ]\n}\n",
},
{
name: "append array value",
original: `{"items":["a"]}`,
options: EditOptions{
Method: MethodJSONSet,
Content: []byte(`"b"`),
ContentProvided: true,
Pointer: "/items/-",
},
expected: "{\n \"items\": [\n \"a\",\n \"b\"\n ]\n}\n",
},
{
name: "merge object",
original: `{"image":{"repository":"app"},"replicas":1}`,
options: EditOptions{
Method: MethodJSONMerge,
Content: []byte(`{"tag":"v2"}`),
ContentProvided: true,
Pointer: "/image",
},
expected: "{\n \"image\": {\n \"repository\": \"app\",\n \"tag\": \"v2\"\n },\n \"replicas\": 1\n}\n",
},
{
name: "RFC 6902 patch",
original: "{\"replicas\":1}\n",
options: EditOptions{
Method: MethodJSONPatch,
Content: []byte(`[{"op":"replace","path":"/replicas","value":2}]`),
ContentProvided: true,
},
expected: "{\"replicas\":2}\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := Apply([]byte(test.original), test.options)
require.NoError(t, err)
assert.Equal(t, test.expected, string(actual))
})
}
}
func TestApplyFilePathSafety(t *testing.T) {
worktree := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(worktree, "values.yaml"), []byte("old"), 0o644))
tests := []struct {
name string
target string
prepare func(*testing.T)
errorText string
}{
{
name: "parent traversal",
target: "../outside",
errorText: "escapes the repository root",
},
{
name: "git internals",
target: ".git/config",
errorText: "must not be inside .git",
},
{
name: "symbolic link",
target: "link",
prepare: func(t *testing.T) {
t.Helper()
require.NoError(t, os.Symlink("values.yaml", filepath.Join(worktree, "link")))
},
errorText: "contains symbolic link",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if test.prepare != nil {
test.prepare(t)
}
_, err := ApplyFile(worktree, test.target, textOptions(MethodOverwrite, "new"), false)
require.ErrorContains(t, err, test.errorText)
})
}
}
func TestApplyFileCreatesEmptyFile(t *testing.T) {
worktree := t.TempDir()
changed, err := ApplyFile(worktree, "empty.txt", textOptions(MethodOverwrite, ""), true)
require.NoError(t, err)
assert.True(t, changed)
content, err := os.ReadFile(filepath.Join(worktree, "empty.txt"))
require.NoError(t, err)
assert.Empty(t, content)
}
func textOptions(method Method, content string) EditOptions {
return EditOptions{
Method: method,
Content: []byte(content),
ContentProvided: true,
ExpectedMatches: 1,
}
}

@ -0,0 +1,307 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"slices"
"strings"
"time"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
const (
// TransportClone uses the Git smart protocol to clone, commit, and push.
TransportClone = "clone"
// TransportAPI uses the repository provider's HTTP API without cloning.
TransportAPI = "api"
)
// ValidTransports returns the supported remote update backends.
func ValidTransports() []string {
return []string{TransportClone, TransportAPI}
}
// TLSOptions controls HTTPS server and client certificate handling.
type TLSOptions struct {
CAFile string
ClientCertificate string
ClientKey string
InsecureSkipTLS bool
}
type loadedTLSOptions struct {
caBundle []byte
clientCertificate []byte
clientKey []byte
insecureSkipTLS bool
}
// RepositoryOptions describes a remote edit-commit transaction.
type RepositoryOptions struct {
RepositoryURL string
Transport string
Branch string
BaseBranch string
CreateBranch bool
TargetFile string
CreateFile bool
Edit EditOptions
Auth AuthOptions
TLS TLSOptions
API APIOptions
AuthorName string
AuthorEmail string
CommitMessage string
Depth int
DryRun bool
Push bool
Progress io.Writer
Now func() time.Time
}
// Result summarizes a repository update.
type Result struct {
Changed bool
Committed bool
Pushed bool
CommitHash string
TargetFile string
Branch string
Transport string
}
// Update applies exactly one file edit and commits it through either a private
// temporary clone or the repository provider API. It never invokes the git
// executable.
func Update(ctx context.Context, opts RepositoryOptions) (Result, error) {
if opts.Transport == "" {
opts.Transport = TransportClone
}
result := Result{TargetFile: opts.TargetFile, Branch: opts.Branch, Transport: opts.Transport}
if err := validateRepositoryOptions(opts); err != nil {
return result, err
}
targetFile, err := validateTargetPath(opts.TargetFile)
if err != nil {
return result, err
}
result.TargetFile = targetFile
if opts.Transport == TransportAPI {
return updateViaAPI(ctx, opts, result)
}
auth, err := BuildAuth(opts.RepositoryURL, opts.Auth)
if err != nil {
return result, fmt.Errorf("configure Git authentication: %w", err)
}
tlsOptions, err := opts.TLS.load()
if err != nil {
return result, err
}
cloneBranch := opts.Branch
if opts.CreateBranch {
cloneBranch = opts.BaseBranch
}
cloneReference := plumbing.NewBranchReferenceName(cloneBranch)
worktreeDir, err := os.MkdirTemp("", "helm-git-update-*")
if err != nil {
return result, fmt.Errorf("create temporary worktree: %w", err)
}
slog.Debug("created temporary Git worktree", "path", worktreeDir)
defer os.RemoveAll(worktreeDir)
repository, err := git.PlainCloneContext(ctx, worktreeDir, false, &git.CloneOptions{
URL: opts.RepositoryURL,
Auth: auth,
ReferenceName: cloneReference,
SingleBranch: !opts.CreateBranch,
Depth: opts.Depth,
Progress: opts.Progress,
InsecureSkipTLS: tlsOptions.insecureSkipTLS,
ClientCert: tlsOptions.clientCertificate,
ClientKey: tlsOptions.clientKey,
CABundle: tlsOptions.caBundle,
})
if err != nil {
return result, fmt.Errorf("clone branch %q: %w", cloneBranch, err)
}
worktree, err := repository.Worktree()
if err != nil {
return result, fmt.Errorf("open cloned worktree: %w", err)
}
if opts.CreateBranch {
remoteTarget := plumbing.NewRemoteReferenceName("origin", opts.Branch)
if _, err := repository.Reference(remoteTarget, true); err == nil {
return result, fmt.Errorf("cannot create branch %q: it already exists on the remote", opts.Branch)
} else if !errors.Is(err, plumbing.ErrReferenceNotFound) {
return result, fmt.Errorf("check remote branch %q: %w", opts.Branch, err)
}
targetReference := plumbing.NewBranchReferenceName(opts.Branch)
if err := worktree.Checkout(&git.CheckoutOptions{Branch: targetReference, Create: true}); err != nil {
return result, fmt.Errorf("create branch %q from %q: %w", opts.Branch, opts.BaseBranch, err)
}
}
changed, err := ApplyFile(worktreeDir, targetFile, opts.Edit, opts.CreateFile)
if err != nil {
return result, err
}
result.Changed = changed
if !changed || opts.DryRun {
return result, nil
}
if _, err := worktree.Add(targetFile); err != nil {
return result, fmt.Errorf("stage target file %q: %w", targetFile, err)
}
now := time.Now
if opts.Now != nil {
now = opts.Now
}
signature := &object.Signature{
Name: opts.AuthorName,
Email: opts.AuthorEmail,
When: now(),
}
hash, err := worktree.Commit(opts.CommitMessage, &git.CommitOptions{
Author: signature,
Committer: signature,
})
if err != nil {
return result, fmt.Errorf("commit target file %q: %w", targetFile, err)
}
result.Committed = true
result.CommitHash = hash.String()
if !opts.Push {
return result, nil
}
targetReference := plumbing.NewBranchReferenceName(opts.Branch)
refspec := config.RefSpec(fmt.Sprintf("%s:%s", targetReference, targetReference))
if err := repository.PushContext(ctx, &git.PushOptions{
Auth: auth,
RefSpecs: []config.RefSpec{refspec},
Progress: opts.Progress,
InsecureSkipTLS: tlsOptions.insecureSkipTLS,
ClientCert: tlsOptions.clientCertificate,
ClientKey: tlsOptions.clientKey,
CABundle: tlsOptions.caBundle,
}); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
return result, fmt.Errorf("push branch %q: %w", opts.Branch, err)
}
result.Pushed = true
return result, nil
}
func validateRepositoryOptions(opts RepositoryOptions) error {
if opts.RepositoryURL == "" {
return errors.New("repository URL is required")
}
transport := opts.Transport
if transport == "" {
transport = TransportClone
}
if !slices.Contains(ValidTransports(), transport) {
return fmt.Errorf("unknown transport %q (supported: %s)", transport, strings.Join(ValidTransports(), ", "))
}
if _, err := validateTargetPath(opts.TargetFile); err != nil {
return err
}
if err := validateEditOptions(opts.Edit); err != nil {
return fmt.Errorf("invalid edit: %w", err)
}
if opts.Branch == "" {
return errors.New("target branch is required")
}
if err := plumbing.NewBranchReferenceName(opts.Branch).Validate(); err != nil {
return fmt.Errorf("invalid target branch %q: %w", opts.Branch, err)
}
if opts.CreateBranch {
if opts.BaseBranch == "" {
return errors.New("--base-branch is required with --create-branch")
}
if opts.BaseBranch == opts.Branch {
return errors.New("target branch and base branch must differ when creating a branch")
}
if err := plumbing.NewBranchReferenceName(opts.BaseBranch).Validate(); err != nil {
return fmt.Errorf("invalid base branch %q: %w", opts.BaseBranch, err)
}
} else if opts.BaseBranch != "" {
return errors.New("--base-branch requires --create-branch")
}
if opts.AuthorName == "" {
return errors.New("commit author name is required")
}
if opts.AuthorEmail == "" {
return errors.New("commit author email is required")
}
if opts.CommitMessage == "" {
return errors.New("commit message is required")
}
if opts.Depth < 0 {
return errors.New("clone depth must be zero or greater")
}
if transport == TransportAPI {
if err := validateAPIOptions(opts); err != nil {
return err
}
}
return nil
}
func (opts TLSOptions) load() (loadedTLSOptions, error) {
loaded := loadedTLSOptions{insecureSkipTLS: opts.InsecureSkipTLS}
if (opts.ClientCertificate == "") != (opts.ClientKey == "") {
return loaded, errors.New("--git-cert-file and --git-key-file must be set together")
}
var err error
if opts.CAFile != "" {
loaded.caBundle, err = os.ReadFile(opts.CAFile)
if err != nil {
return loaded, fmt.Errorf("read Git CA file: %w", err)
}
}
if opts.ClientCertificate != "" {
loaded.clientCertificate, err = os.ReadFile(opts.ClientCertificate)
if err != nil {
return loaded, fmt.Errorf("read Git client certificate: %w", err)
}
loaded.clientKey, err = os.ReadFile(opts.ClientKey)
if err != nil {
return loaded, fmt.Errorf("read Git client key: %w", err)
}
}
return loaded, nil
}

@ -0,0 +1,205 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"context"
"os"
"path/filepath"
"testing"
"time"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdatePushesExistingBranch(t *testing.T) {
remotePath, remote := createRemoteRepository(t)
result, err := Update(context.Background(), repositoryFixture(remotePath, EditOptions{
Method: MethodYAMLSet,
Content: []byte("v2"),
ContentProvided: true,
Pointer: "/image/tag",
}))
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, result.Committed)
assert.True(t, result.Pushed)
assert.Len(t, result.CommitHash, 40)
commit := branchCommit(t, remote, "main")
assert.Equal(t, "update image tag", commit.Message)
assert.Equal(t, "Automation", commit.Author.Name)
assert.Equal(t, "automation@example.com", commit.Author.Email)
assert.Equal(t, "image:\n repository: example/app\n tag: v2\n", commitFile(t, commit, "values.yaml"))
}
func TestUpdateDryRunDoesNotPush(t *testing.T) {
remotePath, remote := createRemoteRepository(t)
before := branchCommit(t, remote, "main").Hash
options := repositoryFixture(remotePath, textOptions(MethodOverwrite, "changed\n"))
options.DryRun = true
result, err := Update(context.Background(), options)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Committed)
assert.False(t, result.Pushed)
assert.Equal(t, before, branchCommit(t, remote, "main").Hash)
}
func TestUpdateReportsNoChange(t *testing.T) {
remotePath, remote := createRemoteRepository(t)
before := branchCommit(t, remote, "main").Hash
result, err := Update(context.Background(), repositoryFixture(remotePath, EditOptions{
Method: MethodYAMLSet,
Content: []byte("v1"),
ContentProvided: true,
Pointer: "/image/tag",
}))
require.NoError(t, err)
assert.False(t, result.Changed)
assert.False(t, result.Committed)
assert.False(t, result.Pushed)
assert.Equal(t, before, branchCommit(t, remote, "main").Hash)
}
func TestUpdateCreatesBranch(t *testing.T) {
remotePath, remote := createRemoteRepository(t)
options := repositoryFixture(remotePath, textOptions(MethodOverwrite, "new branch\n"))
options.Branch = "automation/image-update"
options.BaseBranch = "main"
options.CreateBranch = true
result, err := Update(context.Background(), options)
require.NoError(t, err)
assert.True(t, result.Pushed)
assert.Equal(t, "new branch\n", commitFile(t, branchCommit(t, remote, "automation/image-update"), "values.yaml"))
assert.Equal(t, "image:\n repository: example/app\n tag: v1\n", commitFile(t, branchCommit(t, remote, "main"), "values.yaml"))
}
func TestUpdateRefusesToCreateExistingBranch(t *testing.T) {
remotePath, _ := createRemoteRepository(t)
options := repositoryFixture(remotePath, textOptions(MethodOverwrite, "changed\n"))
options.Branch = "main"
options.BaseBranch = "other"
options.CreateBranch = true
// Add an independent base branch so the command can clone it before checking
// whether the target already exists.
seedPath := filepath.Join(t.TempDir(), "other-seed")
seed, err := git.PlainClone(seedPath, false, &git.CloneOptions{
URL: remotePath,
ReferenceName: plumbing.NewBranchReferenceName("main"),
SingleBranch: true,
})
require.NoError(t, err)
worktree, err := seed.Worktree()
require.NoError(t, err)
require.NoError(t, worktree.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName("other"),
Create: true,
}))
require.NoError(t, seed.Push(&git.PushOptions{
RefSpecs: []config.RefSpec{"refs/heads/other:refs/heads/other"},
}))
_, err = Update(context.Background(), options)
require.ErrorContains(t, err, `branch "main": it already exists on the remote`)
}
func createRemoteRepository(t *testing.T) (string, *git.Repository) {
t.Helper()
root := t.TempDir()
seedPath := filepath.Join(root, "seed")
remotePath := filepath.Join(root, "remote.git")
seed, err := git.PlainInit(seedPath, false)
require.NoError(t, err)
require.NoError(t, seed.Storer.SetReference(plumbing.NewSymbolicReference(
plumbing.HEAD,
plumbing.NewBranchReferenceName("main"),
)))
require.NoError(t, os.WriteFile(
filepath.Join(seedPath, "values.yaml"),
[]byte("image:\n repository: example/app\n tag: v1\n"),
0o644,
))
worktree, err := seed.Worktree()
require.NoError(t, err)
_, err = worktree.Add("values.yaml")
require.NoError(t, err)
_, err = worktree.Commit("initial", &git.CommitOptions{
Author: &object.Signature{
Name: "Initial Author",
Email: "initial@example.com",
When: time.Unix(1_700_000_000, 0).UTC(),
},
})
require.NoError(t, err)
remote, err := git.PlainInit(remotePath, true)
require.NoError(t, err)
_, err = seed.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}})
require.NoError(t, err)
require.NoError(t, seed.Push(&git.PushOptions{
RefSpecs: []config.RefSpec{"refs/heads/main:refs/heads/main"},
}))
return remotePath, remote
}
func repositoryFixture(repositoryURL string, edit EditOptions) RepositoryOptions {
return RepositoryOptions{
RepositoryURL: repositoryURL,
Branch: "main",
TargetFile: "values.yaml",
Edit: edit,
Auth: AuthOptions{Type: AuthNone},
AuthorName: "Automation",
AuthorEmail: "automation@example.com",
CommitMessage: "update image tag",
Depth: 1,
Push: true,
Now: func() time.Time {
return time.Unix(1_700_000_100, 0).UTC()
},
}
}
func branchCommit(t *testing.T, repository *git.Repository, branch string) *object.Commit {
t.Helper()
reference, err := repository.Reference(plumbing.NewBranchReferenceName(branch), true)
require.NoError(t, err)
commit, err := repository.CommitObject(reference.Hash())
require.NoError(t, err)
return commit
}
func commitFile(t *testing.T, commit *object.Commit, filename string) string {
t.Helper()
file, err := commit.File(filename)
require.NoError(t, err)
content, err := file.Contents()
require.NoError(t, err)
return content
}

@ -0,0 +1,227 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"net/http"
gitlab "gitlab.com/gitlab-org/api/client-go/v2"
"golang.org/x/oauth2"
)
func updateViaGitLabAPI(ctx context.Context, opts RepositoryOptions, result Result) (Result, error) {
baseURL, project, err := resolveGitLabCoordinates(opts.RepositoryURL, opts.API)
if err != nil {
return result, err
}
client, err := newGitLabClient(opts, baseURL)
if err != nil {
return result, err
}
readBranch := opts.Branch
if opts.CreateBranch {
if err := requireGitLabBranch(ctx, client, project, opts.Branch, false); err != nil {
return result, err
}
if err := requireGitLabBranch(ctx, client, project, opts.BaseBranch, true); err != nil {
return result, err
}
readBranch = opts.BaseBranch
} else if err := requireGitLabBranch(ctx, client, project, opts.Branch, true); err != nil {
return result, err
}
ref := readBranch
file, response, getErr := client.RepositoryFiles.GetFile(
project,
result.TargetFile,
&gitlab.GetFileOptions{Ref: &ref},
gitlab.WithContext(ctx),
)
existed := true
var original []byte
var lastCommitID string
switch {
case getErr == nil:
original, err = decodeGitLabFile(file)
if err != nil {
return result, fmt.Errorf("decode GitLab file %q: %w", result.TargetFile, err)
}
lastCommitID = file.LastCommitID
case isGitLabStatus(response, http.StatusNotFound):
if !opts.CreateFile {
return result, fmt.Errorf("target file %q does not exist on branch %q (use --create-file to create it)", result.TargetFile, readBranch)
}
existed = false
original = initialContent(opts.Edit.Method)
default:
return result, fmt.Errorf("read target file %q through GitLab API: %w", result.TargetFile, getErr)
}
updated, err := Apply(original, opts.Edit)
if err != nil {
return result, fmt.Errorf("edit %q: %w", result.TargetFile, err)
}
result.Changed = !existed || !bytes.Equal(original, updated)
if !result.Changed || opts.DryRun {
return result, nil
}
action := gitlab.FileUpdate
if !existed {
action = gitlab.FileCreate
}
encoding := "base64"
content := base64.StdEncoding.EncodeToString(updated)
commitOptions := &gitlab.CreateCommitOptions{
Branch: &opts.Branch,
CommitMessage: &opts.CommitMessage,
AuthorName: &opts.AuthorName,
AuthorEmail: &opts.AuthorEmail,
Actions: []*gitlab.CommitActionOptions{{
Action: &action,
FilePath: &result.TargetFile,
Content: &content,
Encoding: &encoding,
}},
}
if existed {
commitOptions.Actions[0].LastCommitID = &lastCommitID
}
if opts.CreateBranch {
commitOptions.StartBranch = &opts.BaseBranch
}
commit, _, err := client.Commits.CreateCommit(project, commitOptions, gitlab.WithContext(ctx))
if err != nil {
return result, fmt.Errorf("commit target file %q through GitLab API: %w", result.TargetFile, err)
}
result.Committed = true
result.Pushed = true
result.CommitHash = commit.ID
return result, nil
}
func requireGitLabBranch(ctx context.Context, client *gitlab.Client, project, branch string, mustExist bool) error {
_, response, err := client.Branches.GetBranch(project, branch, gitlab.WithContext(ctx))
exists := err == nil
if err != nil && !isGitLabStatus(response, http.StatusNotFound) {
return fmt.Errorf("check GitLab branch %q: %w", branch, err)
}
if mustExist && !exists {
return fmt.Errorf("GitLab branch %q does not exist", branch)
}
if !mustExist && exists {
return fmt.Errorf("cannot create branch %q: it already exists on GitLab", branch)
}
return nil
}
func newGitLabClient(opts RepositoryOptions, baseURL string) (*gitlab.Client, error) {
httpClient, err := opts.TLS.httpClient()
if err != nil {
return nil, err
}
clientOptions := []gitlab.ClientOptionFunc{
gitlab.WithBaseURL(baseURL),
gitlab.WithHTTPClient(httpClient),
gitlab.WithOnlyIdempotentRetries(),
}
tokenType, err := resolveAPITokenType(opts.API.TokenType, opts.Auth.Type)
if err != nil {
return nil, err
}
switch tokenType {
case APITokenPrivate:
return gitlab.NewClient(opts.Auth.Token, clientOptions...)
case APITokenOAuth:
source := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: opts.Auth.Token})
return gitlab.NewAuthSourceClient(gitlab.OAuthTokenSource{TokenSource: source}, clientOptions...)
case APITokenJob:
return gitlab.NewJobClient(opts.Auth.Token, clientOptions...)
default:
return nil, fmt.Errorf("unsupported GitLab API token type %q", tokenType)
}
}
func resolveGitLabCoordinates(repositoryURL string, apiOpts APIOptions) (string, string, error) {
return resolveAPICoordinates(repositoryURL, apiOpts, "/api/v4/", "GitLab")
}
func inferGitLabCoordinates(repositoryURL string) (string, string, error) {
host, project, scheme, err := inferRepositoryCoordinates(repositoryURL)
if err != nil {
return "", "", err
}
return fmt.Sprintf("%s://%s/api/v4/", scheme, host), project, nil
}
func decodeGitLabFile(file *gitlab.File) ([]byte, error) {
switch file.Encoding {
case "", "base64":
return base64.StdEncoding.DecodeString(file.Content)
case "text":
return []byte(file.Content), nil
default:
return nil, fmt.Errorf("unsupported content encoding %q", file.Encoding)
}
}
func isGitLabStatus(response *gitlab.Response, status int) bool {
return response != nil && response.StatusCode == status
}
func (opts TLSOptions) httpClient() (*http.Client, error) {
loaded, err := opts.load()
if err != nil {
return nil, err
}
transport := http.DefaultTransport.(*http.Transport).Clone()
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: loaded.insecureSkipTLS, //nolint:gosec
}
if len(loaded.caBundle) != 0 {
roots, err := x509.SystemCertPool()
if err != nil {
roots = x509.NewCertPool()
}
if !roots.AppendCertsFromPEM(loaded.caBundle) {
return nil, errors.New("git CA file does not contain a valid certificate")
}
tlsConfig.RootCAs = roots
}
if len(loaded.clientCertificate) != 0 {
certificate, err := tls.X509KeyPair(loaded.clientCertificate, loaded.clientKey)
if err != nil {
return nil, fmt.Errorf("load Git client certificate and key: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{certificate}
}
transport.TLSClientConfig = tlsConfig
return &http.Client{Transport: transport}, nil
}

@ -0,0 +1,306 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateViaGitLabAPI(t *testing.T) {
original := "image:\n tag: v1\n"
var commitRequest gitLabCommitRequest
requests := []string{}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests = append(requests, request.Method+" "+request.URL.EscapedPath())
assert.Equal(t, "test-token", request.Header.Get("PRIVATE-TOKEN"))
writer.Header().Set("Content-Type", "application/json")
switch len(requests) {
case 1:
assert.Contains(t, request.URL.EscapedPath(), "/repository/branches/main")
fmt.Fprint(writer, `{"name":"main","can_push":true}`)
case 2:
assert.Contains(t, request.URL.EscapedPath(), "/repository/files/")
assert.Equal(t, "main", request.URL.Query().Get("ref"))
writeGitLabFileResponse(t, writer, "values.yaml", original, "file-commit-1")
case 3:
assert.Equal(t, http.MethodPost, request.Method)
assert.Contains(t, request.URL.EscapedPath(), "/repository/commits")
if !assert.NoError(t, json.NewDecoder(request.Body).Decode(&commitRequest)) {
http.Error(writer, "invalid request body", http.StatusBadRequest)
return
}
fmt.Fprint(writer, `{"id":"0123456789abcdef","short_id":"01234567"}`)
default:
t.Errorf("unexpected request: %s %s", request.Method, request.URL)
http.Error(writer, "unexpected request", http.StatusInternalServerError)
}
}))
defer server.Close()
options := apiRepositoryFixture(server.URL)
result, err := Update(context.Background(), options)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, result.Committed)
assert.True(t, result.Pushed)
assert.Equal(t, TransportAPI, result.Transport)
assert.Equal(t, "0123456789abcdef", result.CommitHash)
require.Len(t, commitRequest.Actions, 1)
assert.Equal(t, "main", commitRequest.Branch)
assert.Equal(t, "update", commitRequest.Actions[0].Action)
assert.Equal(t, "values.yaml", commitRequest.Actions[0].FilePath)
assert.Equal(t, "file-commit-1", commitRequest.Actions[0].LastCommitID)
assert.Equal(t, "base64", commitRequest.Actions[0].Encoding)
updated, err := base64.StdEncoding.DecodeString(commitRequest.Actions[0].Content)
require.NoError(t, err)
assert.Equal(t, "image:\n tag: v2\n", string(updated))
}
func TestUpdateViaGitLabAPIDryRunDoesNotCommit(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestCount++
writer.Header().Set("Content-Type", "application/json")
switch requestCount {
case 1:
fmt.Fprint(writer, `{"name":"main","can_push":true}`)
case 2:
writeGitLabFileResponse(t, writer, "values.yaml", "image:\n tag: v1\n", "file-commit-1")
default:
t.Errorf("dry run made unexpected request: %s %s", request.Method, request.URL)
http.Error(writer, "unexpected request", http.StatusInternalServerError)
}
}))
defer server.Close()
options := apiRepositoryFixture(server.URL)
options.DryRun = true
result, err := Update(context.Background(), options)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Committed)
assert.False(t, result.Pushed)
assert.Equal(t, 2, requestCount)
}
func TestUpdateViaGitLabAPICreatesBranchAndFile(t *testing.T) {
requestCount := 0
var commitRequest gitLabCommitRequest
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestCount++
writer.Header().Set("Content-Type", "application/json")
switch requestCount {
case 1:
http.Error(writer, `{"message":"404 Branch Not Found"}`, http.StatusNotFound)
case 2:
fmt.Fprint(writer, `{"name":"main","can_push":true}`)
case 3:
assert.Equal(t, "main", request.URL.Query().Get("ref"))
http.Error(writer, `{"message":"404 File Not Found"}`, http.StatusNotFound)
case 4:
if !assert.NoError(t, json.NewDecoder(request.Body).Decode(&commitRequest)) {
http.Error(writer, "invalid request body", http.StatusBadRequest)
return
}
fmt.Fprint(writer, `{"id":"new-branch-commit"}`)
default:
t.Errorf("unexpected request: %s %s", request.Method, request.URL)
}
}))
defer server.Close()
options := apiRepositoryFixture(server.URL)
options.Branch = "feature/demo"
options.BaseBranch = "main"
options.CreateBranch = true
options.TargetFile = "config/new.yaml"
options.CreateFile = true
options.Edit = EditOptions{
Method: MethodYAMLSet,
Content: []byte("true"),
ContentProvided: true,
Pointer: "/enabled",
}
result, err := Update(context.Background(), options)
require.NoError(t, err)
assert.True(t, result.Pushed)
assert.Equal(t, "feature/demo", commitRequest.Branch)
assert.Equal(t, "main", commitRequest.StartBranch)
require.Len(t, commitRequest.Actions, 1)
assert.Equal(t, "create", commitRequest.Actions[0].Action)
assert.Empty(t, commitRequest.Actions[0].LastCommitID)
updated, err := base64.StdEncoding.DecodeString(commitRequest.Actions[0].Content)
require.NoError(t, err)
assert.Equal(t, "{enabled: true}\n", string(updated))
}
func TestInferGitLabCoordinates(t *testing.T) {
tests := []struct {
name string
repository string
expectedURL string
project string
}{
{
name: "HTTPS",
repository: "https://gitlab.example.com/acme/charts.git",
expectedURL: "https://gitlab.example.com/api/v4/",
project: "acme/charts",
},
{
name: "SSH URL",
repository: "ssh://git@gitlab.example.com/acme/charts.git",
expectedURL: "https://gitlab.example.com/api/v4/",
project: "acme/charts",
},
{
name: "SCP syntax",
repository: "git@gitlab.example.com:acme/charts.git",
expectedURL: "https://gitlab.example.com/api/v4/",
project: "acme/charts",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
baseURL, project, err := inferGitLabCoordinates(test.repository)
require.NoError(t, err)
assert.Equal(t, test.expectedURL, baseURL)
assert.Equal(t, test.project, project)
})
}
}
func TestValidateAPIOptions(t *testing.T) {
options := apiRepositoryFixture("https://gitlab.example.com")
tests := []struct {
name string
mutate func(*RepositoryOptions)
errorText string
}{
{
name: "missing token",
mutate: func(options *RepositoryOptions) {
options.Auth.Token = ""
},
errorText: "requires --token",
},
{
name: "SSH auth is rejected",
mutate: func(options *RepositoryOptions) {
options.Auth.Type = AuthSSHKey
},
errorText: "not supported by the gitlab API transport",
},
{
name: "push false is rejected",
mutate: func(options *RepositoryOptions) {
options.Push = false
},
errorText: "requires --push=true",
},
{
name: "unknown provider",
mutate: func(options *RepositoryOptions) {
options.API.Provider = "github"
},
errorText: "unknown API provider",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
candidate := options
test.mutate(&candidate)
err := validateRepositoryOptions(candidate)
require.ErrorContains(t, err, test.errorText)
})
}
}
func apiRepositoryFixture(serverURL string) RepositoryOptions {
return RepositoryOptions{
RepositoryURL: serverURL + "/group/repo.git",
Transport: TransportAPI,
Branch: "main",
TargetFile: "values.yaml",
Edit: EditOptions{
Method: MethodYAMLSet,
Content: []byte("v2"),
ContentProvided: true,
Pointer: "/image/tag",
},
Auth: AuthOptions{
Type: AuthToken,
Token: "test-token",
},
API: APIOptions{
Provider: APIProviderGitLab,
BaseURL: serverURL + "/api/v4/",
Project: "group/repo",
TokenType: APITokenPrivate,
},
AuthorName: "Automation",
AuthorEmail: "automation@example.com",
CommitMessage: "update through API",
Depth: 1,
Push: true,
}
}
func writeGitLabFileResponse(t *testing.T, writer http.ResponseWriter, path, content, lastCommitID string) {
t.Helper()
response := map[string]any{
"file_name": path[strings.LastIndex(path, "/")+1:],
"file_path": path,
"encoding": "base64",
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"last_commit_id": lastCommitID,
}
require.NoError(t, json.NewEncoder(writer).Encode(response))
}
type gitLabCommitRequest struct {
Branch string `json:"branch"`
StartBranch string `json:"start_branch"`
CommitMessage string `json:"commit_message"`
AuthorName string `json:"author_name"`
AuthorEmail string `json:"author_email"`
Actions []gitLabCommitActionRequest `json:"actions"`
}
type gitLabCommitActionRequest struct {
Action string `json:"action"`
FilePath string `json:"file_path"`
Content string `json:"content"`
Encoding string `json:"encoding"`
LastCommitID string `json:"last_commit_id"`
}

@ -0,0 +1,337 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"strconv"
jsonpatch "github.com/evanphx/json-patch/v5"
)
func applyJSON(original []byte, opts EditOptions) ([]byte, error) {
document, err := decodeJSON(original)
if err != nil {
return nil, fmt.Errorf("decode target JSON: %w", err)
}
segments, err := parsePointer(opts.Pointer)
if err != nil {
return nil, err
}
var changed bool
switch opts.Method {
case MethodJSONSet:
value, valueErr := decodeJSON(opts.Content)
if valueErr != nil {
return nil, fmt.Errorf("decode JSON content: %w", valueErr)
}
document, changed, err = setJSONValue(document, segments, value, opts.CreatePath)
case MethodJSONMerge:
value, valueErr := decodeJSON(opts.Content)
if valueErr != nil {
return nil, fmt.Errorf("decode JSON content: %w", valueErr)
}
document, changed, err = mergeJSONValue(document, segments, value, opts.CreatePath)
case MethodJSONDelete:
document, changed, err = deleteJSONValue(document, segments)
default:
return nil, fmt.Errorf("unsupported JSON method %q", opts.Method)
}
if err != nil {
return nil, err
}
if !changed {
return original, nil
}
updated, err := json.MarshalIndent(document, "", " ")
if err != nil {
return nil, fmt.Errorf("encode target JSON: %w", err)
}
return append(updated, '\n'), nil
}
func applyJSONPatch(original []byte, opts EditOptions) ([]byte, error) {
originalValue, err := decodeJSON(original)
if err != nil {
return nil, fmt.Errorf("decode target JSON: %w", err)
}
var updated []byte
switch opts.Method {
case MethodJSONPatch:
patch, err := jsonpatch.DecodePatch(opts.Content)
if err != nil {
return nil, fmt.Errorf("decode RFC 6902 JSON patch: %w", err)
}
updated, err = patch.Apply(original)
if err != nil {
return nil, fmt.Errorf("apply RFC 6902 JSON patch: %w", err)
}
case MethodJSONMergePatch:
if _, err := decodeJSON(opts.Content); err != nil {
return nil, fmt.Errorf("decode RFC 7386 JSON merge patch: %w", err)
}
updated, err = jsonpatch.MergePatch(original, opts.Content)
if err != nil {
return nil, fmt.Errorf("apply RFC 7386 JSON merge patch: %w", err)
}
default:
return nil, fmt.Errorf("unsupported JSON patch method %q", opts.Method)
}
updatedValue, err := decodeJSON(updated)
if err != nil {
return nil, fmt.Errorf("decode patched JSON: %w", err)
}
if reflect.DeepEqual(originalValue, updatedValue) {
return original, nil
}
return preserveJSONNewline(original, updated), nil
}
func decodeJSON(data []byte) (any, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, err
}
if err := ensureJSONEOF(decoder); err != nil {
return nil, err
}
return value, nil
}
func ensureJSONEOF(decoder *json.Decoder) error {
var extra any
err := decoder.Decode(&extra)
if errors.Is(err, io.EOF) {
return nil
}
if err == nil {
return errors.New("multiple JSON values are not allowed")
}
return err
}
func setJSONValue(current any, segments []string, value any, createPath bool) (any, bool, error) {
if len(segments) == 0 {
return value, !reflect.DeepEqual(current, value), nil
}
segment := segments[0]
switch typed := current.(type) {
case map[string]any:
child, exists := typed[segment]
if !exists {
if len(segments) == 1 {
typed[segment] = value
return typed, true, nil
}
if !createPath {
return current, false, fmt.Errorf("path component %q does not exist", segment)
}
child = map[string]any{}
}
updated, changed, err := setJSONValue(child, segments[1:], value, createPath)
if err != nil {
return current, false, err
}
if changed {
typed[segment] = updated
}
return typed, changed, nil
case []any:
if segment == "-" {
if len(segments) != 1 {
return current, false, errors.New("array append token '-' is only valid at the final path component")
}
return append(typed, value), true, nil
}
index, err := parseArrayIndex(segment, len(typed))
if err != nil {
return current, false, err
}
updated, changed, err := setJSONValue(typed[index], segments[1:], value, createPath)
if err != nil {
return current, false, err
}
if changed {
typed[index] = updated
}
return typed, changed, nil
default:
return current, false, fmt.Errorf("cannot traverse path component %q through %T", segment, current)
}
}
func mergeJSONValue(current any, segments []string, value any, createPath bool) (any, bool, error) {
if len(segments) != 0 {
target, err := getJSONValue(current, segments)
if err != nil {
if !createPath {
return current, false, err
}
valueMap, ok := value.(map[string]any)
if !ok {
return current, false, errors.New("json-merge content must be an object")
}
return setJSONValue(current, segments, valueMap, true)
}
merged, changed, err := deepMergeJSON(target, value)
if err != nil || !changed {
return current, changed, err
}
updated, _, err := setJSONValue(current, segments, merged, createPath)
return updated, true, err
}
return deepMergeJSON(current, value)
}
func deepMergeJSON(target, source any) (any, bool, error) {
targetMap, ok := target.(map[string]any)
if !ok {
return target, false, errors.New("json-merge target must be an object")
}
sourceMap, ok := source.(map[string]any)
if !ok {
return target, false, errors.New("json-merge content must be an object")
}
changed := false
for key, sourceValue := range sourceMap {
targetValue, exists := targetMap[key]
if !exists {
targetMap[key] = sourceValue
changed = true
continue
}
if _, sourceIsMap := sourceValue.(map[string]any); sourceIsMap {
if _, targetIsMap := targetValue.(map[string]any); targetIsMap {
merged, nestedChanged, err := deepMergeJSON(targetValue, sourceValue)
if err != nil {
return target, false, err
}
targetMap[key] = merged
changed = changed || nestedChanged
continue
}
}
if !reflect.DeepEqual(targetValue, sourceValue) {
targetMap[key] = sourceValue
changed = true
}
}
return targetMap, changed, nil
}
func getJSONValue(current any, segments []string) (any, error) {
for _, segment := range segments {
switch typed := current.(type) {
case map[string]any:
next, ok := typed[segment]
if !ok {
return nil, fmt.Errorf("path component %q does not exist", segment)
}
current = next
case []any:
index, err := parseArrayIndex(segment, len(typed))
if err != nil {
return nil, err
}
current = typed[index]
default:
return nil, fmt.Errorf("cannot traverse path component %q through %T", segment, current)
}
}
return current, nil
}
func deleteJSONValue(current any, segments []string) (any, bool, error) {
if len(segments) == 0 {
return current, false, errors.New("deleting the document root is not supported")
}
if len(segments) == 1 {
switch typed := current.(type) {
case map[string]any:
if _, ok := typed[segments[0]]; !ok {
return current, false, fmt.Errorf("path component %q does not exist", segments[0])
}
delete(typed, segments[0])
return typed, true, nil
case []any:
index, err := parseArrayIndex(segments[0], len(typed))
if err != nil {
return current, false, err
}
return append(typed[:index], typed[index+1:]...), true, nil
default:
return current, false, fmt.Errorf("cannot delete path component %q from %T", segments[0], current)
}
}
segment := segments[0]
switch typed := current.(type) {
case map[string]any:
child, ok := typed[segment]
if !ok {
return current, false, fmt.Errorf("path component %q does not exist", segment)
}
updated, changed, err := deleteJSONValue(child, segments[1:])
if changed {
typed[segment] = updated
}
return typed, changed, err
case []any:
index, err := parseArrayIndex(segment, len(typed))
if err != nil {
return current, false, err
}
updated, changed, err := deleteJSONValue(typed[index], segments[1:])
if changed {
typed[index] = updated
}
return typed, changed, err
default:
return current, false, fmt.Errorf("cannot traverse path component %q through %T", segment, current)
}
}
func parseArrayIndex(segment string, length int) (int, error) {
index, err := strconv.Atoi(segment)
if err != nil || index < 0 {
return 0, fmt.Errorf("array path component %q is not a non-negative index", segment)
}
if index >= length {
return 0, fmt.Errorf("array index %d is out of bounds (length %d)", index, length)
}
return index, nil
}
func preserveJSONNewline(original, updated []byte) []byte {
if len(original) > 0 && original[len(original)-1] == '\n' && (len(updated) == 0 || updated[len(updated)-1] != '\n') {
return append(updated, '\n')
}
return updated
}

@ -0,0 +1,59 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"fmt"
"strings"
)
// parsePointer implements the token decoding rules from RFC 6901. An empty
// pointer addresses the document root.
func parsePointer(pointer string) ([]string, error) {
if pointer == "" {
return nil, nil
}
if !strings.HasPrefix(pointer, "/") {
return nil, fmt.Errorf("path %q must be an RFC 6901 JSON pointer beginning with '/'", pointer)
}
raw := strings.Split(pointer[1:], "/")
segments := make([]string, len(raw))
for i, segment := range raw {
var decoded strings.Builder
for j := 0; j < len(segment); j++ {
if segment[j] != '~' {
decoded.WriteByte(segment[j])
continue
}
if j+1 >= len(segment) {
return nil, fmt.Errorf("path %q contains an invalid '~' escape", pointer)
}
j++
switch segment[j] {
case '0':
decoded.WriteByte('~')
case '1':
decoded.WriteByte('/')
default:
return nil, fmt.Errorf("path %q contains invalid escape ~%c", pointer, segment[j])
}
}
segments[i] = decoded.String()
}
return segments, nil
}

@ -0,0 +1,390 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gitupdate
import (
"bytes"
"errors"
"fmt"
"io"
"reflect"
"go.yaml.in/yaml/v3"
)
func applyYAML(original []byte, opts EditOptions) ([]byte, error) {
document, err := decodeYAMLDocument(original)
if err != nil {
return nil, fmt.Errorf("decode target YAML: %w", err)
}
segments, err := parsePointer(opts.Pointer)
if err != nil {
return nil, err
}
root := document.Content[0]
var changed bool
switch opts.Method {
case MethodYAMLSet:
value, valueErr := decodeYAMLValue(opts.Content)
if valueErr != nil {
return nil, fmt.Errorf("decode YAML content: %w", valueErr)
}
changed, err = setYAMLValue(root, segments, value, opts.CreatePath)
case MethodYAMLMerge:
value, valueErr := decodeYAMLValue(opts.Content)
if valueErr != nil {
return nil, fmt.Errorf("decode YAML content: %w", valueErr)
}
changed, err = mergeYAMLAt(root, segments, value, opts.CreatePath)
case MethodYAMLDelete:
changed, err = deleteYAMLValue(root, segments)
default:
return nil, fmt.Errorf("unsupported YAML method %q", opts.Method)
}
if err != nil {
return nil, err
}
if !changed {
return original, nil
}
var output bytes.Buffer
encoder := yaml.NewEncoder(&output)
encoder.SetIndent(2)
if err := encoder.Encode(document); err != nil {
return nil, fmt.Errorf("encode target YAML: %w", err)
}
if err := encoder.Close(); err != nil {
return nil, fmt.Errorf("finish encoding target YAML: %w", err)
}
return output.Bytes(), nil
}
func decodeYAMLDocument(data []byte) (*yaml.Node, error) {
document, err := decodeOneYAML(data)
if err != nil {
return nil, err
}
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 {
return nil, errors.New("expected one YAML document with a root value")
}
return document, nil
}
func decodeYAMLValue(data []byte) (*yaml.Node, error) {
document, err := decodeOneYAML(data)
if err != nil {
return nil, err
}
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 {
return nil, errors.New("expected one YAML value")
}
return cloneYAMLNode(document.Content[0]), nil
}
func decodeOneYAML(data []byte) (*yaml.Node, error) {
decoder := yaml.NewDecoder(bytes.NewReader(data))
var document yaml.Node
if err := decoder.Decode(&document); err != nil {
return nil, err
}
if len(document.Content) == 0 {
return nil, errors.New("empty YAML is not a value")
}
var extra yaml.Node
err := decoder.Decode(&extra)
if err == nil {
return nil, errors.New("multiple YAML documents are not supported")
}
if !errors.Is(err, io.EOF) {
return nil, err
}
if err := validateYAMLUniqueKeys(document.Content[0]); err != nil {
return nil, err
}
return &document, nil
}
func validateYAMLUniqueKeys(node *yaml.Node) error {
if node.Kind == yaml.MappingNode {
seen := map[string]struct{}{}
for i := 0; i+1 < len(node.Content); i += 2 {
key := node.Content[i]
encoded, err := yaml.Marshal(key)
if err != nil {
return fmt.Errorf("encode YAML mapping key: %w", err)
}
identity := string(encoded)
if _, exists := seen[identity]; exists {
return fmt.Errorf("duplicate YAML mapping key %q", key.Value)
}
seen[identity] = struct{}{}
}
}
for _, child := range node.Content {
if err := validateYAMLUniqueKeys(child); err != nil {
return err
}
}
return nil
}
func setYAMLValue(current *yaml.Node, segments []string, value *yaml.Node, createPath bool) (bool, error) {
if len(segments) == 0 {
if equalYAMLValue(current, value) {
return false, nil
}
replacement := cloneYAMLNode(value)
preserveYAMLComments(replacement, current)
*current = *replacement
return true, nil
}
segment := segments[0]
switch current.Kind {
case yaml.MappingNode:
index := yamlMapValueIndex(current, segment)
if index < 0 {
if len(segments) == 1 {
current.Content = append(current.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: segment},
cloneYAMLNode(value),
)
return true, nil
}
if !createPath {
return false, fmt.Errorf("path component %q does not exist", segment)
}
child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
current.Content = append(current.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: segment},
child,
)
return setYAMLValue(child, segments[1:], value, createPath)
}
return setYAMLValue(current.Content[index], segments[1:], value, createPath)
case yaml.SequenceNode:
if segment == "-" {
if len(segments) != 1 {
return false, errors.New("array append token '-' is only valid at the final path component")
}
current.Content = append(current.Content, cloneYAMLNode(value))
return true, nil
}
index, err := parseArrayIndex(segment, len(current.Content))
if err != nil {
return false, err
}
return setYAMLValue(current.Content[index], segments[1:], value, createPath)
case yaml.AliasNode:
return false, fmt.Errorf("cannot traverse path component %q through a YAML alias", segment)
default:
return false, fmt.Errorf("cannot traverse path component %q through YAML kind %d", segment, current.Kind)
}
}
func mergeYAMLAt(current *yaml.Node, segments []string, value *yaml.Node, createPath bool) (bool, error) {
if value.Kind != yaml.MappingNode {
return false, errors.New("yaml-merge content must be a mapping")
}
if len(segments) == 0 {
return deepMergeYAML(current, value)
}
target, err := getYAMLValue(current, segments)
if err != nil {
if !createPath {
return false, err
}
return setYAMLValue(current, segments, value, true)
}
return deepMergeYAML(target, value)
}
func deepMergeYAML(target, source *yaml.Node) (bool, error) {
if target.Kind != yaml.MappingNode {
return false, errors.New("yaml-merge target must be a mapping")
}
if source.Kind != yaml.MappingNode {
return false, errors.New("yaml-merge content must be a mapping")
}
changed := false
for i := 0; i < len(source.Content); i += 2 {
sourceKey := source.Content[i]
sourceValue := source.Content[i+1]
if sourceKey.Kind != yaml.ScalarNode {
return false, errors.New("yaml-merge only supports scalar mapping keys")
}
targetIndex := yamlMapValueIndex(target, sourceKey.Value)
if targetIndex < 0 {
target.Content = append(target.Content, cloneYAMLNode(sourceKey), cloneYAMLNode(sourceValue))
changed = true
continue
}
targetValue := target.Content[targetIndex]
if targetValue.Kind == yaml.MappingNode && sourceValue.Kind == yaml.MappingNode {
nestedChanged, err := deepMergeYAML(targetValue, sourceValue)
if err != nil {
return false, err
}
changed = changed || nestedChanged
continue
}
if equalYAMLValue(targetValue, sourceValue) {
continue
}
replacement := cloneYAMLNode(sourceValue)
preserveYAMLComments(replacement, targetValue)
target.Content[targetIndex] = replacement
changed = true
}
return changed, nil
}
func getYAMLValue(current *yaml.Node, segments []string) (*yaml.Node, error) {
for _, segment := range segments {
switch current.Kind {
case yaml.MappingNode:
index := yamlMapValueIndex(current, segment)
if index < 0 {
return nil, fmt.Errorf("path component %q does not exist", segment)
}
current = current.Content[index]
case yaml.SequenceNode:
index, err := parseArrayIndex(segment, len(current.Content))
if err != nil {
return nil, err
}
current = current.Content[index]
case yaml.AliasNode:
return nil, fmt.Errorf("cannot traverse path component %q through a YAML alias", segment)
default:
return nil, fmt.Errorf("cannot traverse path component %q through YAML kind %d", segment, current.Kind)
}
}
return current, nil
}
func deleteYAMLValue(current *yaml.Node, segments []string) (bool, error) {
if len(segments) == 0 {
return false, errors.New("deleting the document root is not supported")
}
if len(segments) == 1 {
segment := segments[0]
switch current.Kind {
case yaml.MappingNode:
valueIndex := yamlMapValueIndex(current, segment)
if valueIndex < 0 {
return false, fmt.Errorf("path component %q does not exist", segment)
}
keyIndex := valueIndex - 1
current.Content = append(current.Content[:keyIndex], current.Content[valueIndex+1:]...)
return true, nil
case yaml.SequenceNode:
index, err := parseArrayIndex(segment, len(current.Content))
if err != nil {
return false, err
}
current.Content = append(current.Content[:index], current.Content[index+1:]...)
return true, nil
default:
return false, fmt.Errorf("cannot delete path component %q from YAML kind %d", segment, current.Kind)
}
}
segment := segments[0]
switch current.Kind {
case yaml.MappingNode:
index := yamlMapValueIndex(current, segment)
if index < 0 {
return false, fmt.Errorf("path component %q does not exist", segment)
}
return deleteYAMLValue(current.Content[index], segments[1:])
case yaml.SequenceNode:
index, err := parseArrayIndex(segment, len(current.Content))
if err != nil {
return false, err
}
return deleteYAMLValue(current.Content[index], segments[1:])
case yaml.AliasNode:
return false, fmt.Errorf("cannot traverse path component %q through a YAML alias", segment)
default:
return false, fmt.Errorf("cannot traverse path component %q through YAML kind %d", segment, current.Kind)
}
}
func yamlMapValueIndex(mapping *yaml.Node, key string) int {
for i := 0; i+1 < len(mapping.Content); i += 2 {
keyNode := mapping.Content[i]
if keyNode.Kind == yaml.ScalarNode && keyNode.Value == key {
return i + 1
}
}
return -1
}
func equalYAMLValue(left, right *yaml.Node) bool {
var leftValue any
if err := left.Decode(&leftValue); err != nil {
return false
}
var rightValue any
if err := right.Decode(&rightValue); err != nil {
return false
}
return reflect.DeepEqual(leftValue, rightValue)
}
func preserveYAMLComments(target, source *yaml.Node) {
if target.HeadComment == "" {
target.HeadComment = source.HeadComment
}
if target.LineComment == "" {
target.LineComment = source.LineComment
}
if target.FootComment == "" {
target.FootComment = source.FootComment
}
}
func cloneYAMLNode(node *yaml.Node) *yaml.Node {
clones := map[*yaml.Node]*yaml.Node{}
var clone func(*yaml.Node) *yaml.Node
clone = func(source *yaml.Node) *yaml.Node {
if source == nil {
return nil
}
if existing, ok := clones[source]; ok {
return existing
}
target := *source
target.Content = nil
target.Alias = nil
clones[source] = &target
for _, child := range source.Content {
target.Content = append(target.Content, clone(child))
}
target.Alias = clone(source.Alias)
return &target
}
return clone(node)
}

@ -0,0 +1,407 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"helm.sh/helm/v4/internal/gitupdate"
"helm.sh/helm/v4/pkg/cmd/require"
)
const gitDesc = `
Work with files stored in Git repositories.
Git operations are implemented inside the Helm binary and do not require a
separate git executable.
`
const gitUpdateDesc = `
Modify one file in a Git repository, commit the change, and push it.
The edit is deterministic: text selectors fail unless they match the expected
number of locations, and structured edits use RFC 6901 JSON pointers. An empty
pointer addresses the document root. For example:
helm git update https://example.com/platform/config.git \
--branch main --file charts/api/values.yaml \
--method yaml-set --path /image/tag --content 1.4.2
helm git update git@example.com:platform/config.git \
--branch main --file deploy/app.yaml \
--method replace --match 'replicas: 2' --content 'replicas: 3' \
--auth ssh-agent
The default --transport=clone backend uses the Git smart protocol and a
temporary worktree. The --transport=api backend uses a repository service API
to read and commit the file without cloning. GitLab is the default API
provider:
helm git update https://gitlab.example.com/platform/config.git \
--transport api --api-provider gitlab --token-file /run/secrets/git-token \
--branch main --file charts/api/values.yaml \
--method yaml-set --path /image/tag --content 1.4.2
Content can be supplied with --content, --content-env, --content-file, or
--content-stdin. Secret flags also read HELM_GIT_PASSWORD, HELM_GIT_TOKEN, and
HELM_GIT_SSH_KEY_PASSPHRASE when their command-line and file forms are absent.
`
type gitUpdateOptions struct {
repositoryURL string
transport string
branch string
baseBranch string
createBranch bool
targetFile string
createFile bool
method string
content string
contentEnv string
contentFile string
contentStdin bool
match string
matchFile string
regex string
pointer string
createPath bool
expected int
all bool
expand bool
authType string
username string
password string
passwordFile string
token string
tokenFile string
sshPrivateKey string
sshKeyPassphrase string
sshPassphraseFile string
sshKnownHosts string
insecureSSHHostKey bool
caFile string
certFile string
keyFile string
insecureSkipTLS bool
apiProvider string
apiBaseURL string
apiProject string
apiTokenType string
authorName string
authorEmail string
commitMessage string
depth int
dryRun bool
push bool
resolvedContent []byte
contentWasProvided bool
resolvedLiteralMatch string
}
func newGitCmd(out io.Writer) *cobra.Command {
cmd := &cobra.Command{
Use: "git",
Short: "edit files in Git repositories",
Long: gitDesc,
Args: require.NoArgs,
ValidArgsFunction: noMoreArgsCompFunc,
}
cmd.AddCommand(newGitUpdateCmd(out))
return cmd
}
func newGitUpdateCmd(out io.Writer) *cobra.Command {
o := &gitUpdateOptions{}
if value := os.Getenv("GIT_AUTHOR_NAME"); value != "" {
o.authorName = value
} else {
o.authorName = "Helm Git Update"
}
if value := os.Getenv("GIT_AUTHOR_EMAIL"); value != "" {
o.authorEmail = value
} else {
o.authorEmail = "helm-git@localhost"
}
cmd := &cobra.Command{
Use: "update REPOSITORY",
Short: "edit, commit, and push a file in a Git repository",
Long: gitUpdateDesc,
Args: require.ExactArgs(1),
ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return noMoreArgsComp()
},
PreRunE: func(cmd *cobra.Command, args []string) error {
return o.complete(cmd, args)
},
RunE: func(cmd *cobra.Command, _ []string) error {
return o.run(cmd, out)
},
}
flags := cmd.Flags()
flags.StringVar(&o.transport, "transport", gitupdate.TransportClone, "remote update backend ("+strings.Join(gitupdate.ValidTransports(), ", ")+")")
flags.StringVar(&o.branch, "branch", "", "target branch to update")
flags.StringVar(&o.baseBranch, "base-branch", "", "branch from which a new target branch is created")
flags.BoolVar(&o.createBranch, "create-branch", false, "create the target branch from --base-branch")
flags.StringVar(&o.targetFile, "file", "", "file to edit, relative to the repository root")
flags.BoolVar(&o.createFile, "create-file", false, "create the target file and parent directories when absent")
flags.StringVar(&o.method, "method", string(gitupdate.MethodOverwrite), "edit method ("+strings.Join(gitupdate.ValidMethods(), ", ")+")")
flags.StringVar(&o.content, "content", "", "new content or structured value")
flags.StringVar(&o.contentEnv, "content-env", "", "read new content or structured value from the named environment variable")
flags.StringVar(&o.contentFile, "content-file", "", "read new content or structured value from a file")
flags.BoolVar(&o.contentStdin, "content-stdin", false, "read new content or structured value from standard input")
flags.StringVar(&o.match, "match", "", "literal text selector for replace or insert methods")
flags.StringVar(&o.matchFile, "match-file", "", "read the literal text selector from a file")
flags.StringVar(&o.regex, "regex", "", "Go regular expression selector for replace or insert methods")
flags.IntVar(&o.expected, "expected-matches", 1, "required selector match count; 0 accepts any non-zero count")
flags.BoolVar(&o.all, "all", false, "edit all selector matches instead of only the first")
flags.BoolVar(&o.expand, "expand", false, "expand regular expression captures such as $1 in content")
flags.StringVar(&o.pointer, "path", "", "RFC 6901 path used by YAML and JSON set, merge, and delete methods")
flags.BoolVar(&o.createPath, "create-path", false, "create absent mapping components for structured set or merge")
flags.StringVar(&o.authType, "auth", gitupdate.AuthAuto, "authentication mode ("+strings.Join(gitupdate.ValidAuthTypes(), ", ")+")")
flags.StringVar(&o.username, "username", os.Getenv("HELM_GIT_USERNAME"), "HTTP or SSH username")
flags.StringVar(&o.password, "password", "", "HTTP or SSH password (prefer --password-file or HELM_GIT_PASSWORD)")
flags.StringVar(&o.passwordFile, "password-file", "", "read the HTTP or SSH password from a file")
flags.StringVar(&o.token, "token", "", "HTTP token (prefer --token-file or HELM_GIT_TOKEN)")
flags.StringVar(&o.tokenFile, "token-file", "", "read the HTTP token from a file")
flags.StringVar(&o.sshPrivateKey, "ssh-private-key", "", "SSH private key file")
flags.StringVar(&o.sshKeyPassphrase, "ssh-key-passphrase", "", "SSH key passphrase (prefer its file or environment form)")
flags.StringVar(&o.sshPassphraseFile, "ssh-key-passphrase-file", "", "read the SSH key passphrase from a file")
flags.StringVar(&o.sshKnownHosts, "ssh-known-hosts", "", "SSH known_hosts file (defaults to standard SSH known_hosts files)")
flags.BoolVar(&o.insecureSSHHostKey, "insecure-skip-ssh-host-key", false, "skip SSH host-key verification (insecure)")
flags.StringVar(&o.caFile, "ca-file", "", "CA bundle used to verify the Git HTTPS server")
flags.StringVar(&o.certFile, "cert-file", "", "client certificate used for Git mutual TLS")
flags.StringVar(&o.keyFile, "key-file", "", "client key used for Git mutual TLS")
flags.BoolVar(&o.insecureSkipTLS, "insecure-skip-tls-verify", false, "skip Git HTTPS certificate verification (insecure)")
flags.StringVar(&o.apiProvider, "api-provider", gitupdate.APIProviderAuto, "repository API provider ("+strings.Join(gitupdate.ValidAPIProviders(), ", ")+")")
flags.StringVar(&o.apiBaseURL, "api-base-url", "", "repository API base URL (inferred from REPOSITORY)")
flags.StringVar(&o.apiProject, "api-project", "", "repository API project ID or path (inferred from REPOSITORY)")
flags.StringVar(&o.apiTokenType, "api-token-type", gitupdate.APITokenAuto, "repository API token type ("+strings.Join(gitupdate.ValidAPITokenTypes(), ", ")+")")
flags.StringVar(&o.authorName, "author-name", o.authorName, "commit author name")
flags.StringVar(&o.authorEmail, "author-email", o.authorEmail, "commit author email")
flags.StringVarP(&o.commitMessage, "message", "m", "", "commit message (defaults to 'helm git update <file>')")
flags.IntVar(&o.depth, "depth", 1, "clone depth; 0 clones the complete history")
flags.BoolVar(&o.dryRun, "dry-run", false, "read and validate the edit without committing or pushing")
flags.BoolVar(&o.push, "push", true, "push the commit to the remote repository")
_ = cmd.MarkFlagRequired("branch")
_ = cmd.MarkFlagRequired("file")
_ = cmd.RegisterFlagCompletionFunc("method", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return gitupdate.ValidMethods(), cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("auth", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return gitupdate.ValidAuthTypes(), cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("transport", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return gitupdate.ValidTransports(), cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("api-provider", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return gitupdate.ValidAPIProviders(), cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("api-token-type", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return gitupdate.ValidAPITokenTypes(), cobra.ShellCompDirectiveNoFileComp
})
return cmd
}
func (o *gitUpdateOptions) complete(cmd *cobra.Command, args []string) error {
o.repositoryURL = args[0]
if o.commitMessage == "" {
o.commitMessage = "helm git update " + o.targetFile
}
contentSources := 0
if cmd.Flags().Changed("content") {
contentSources++
o.resolvedContent = []byte(o.content)
o.contentWasProvided = true
}
if o.contentEnv != "" {
contentSources++
content, exists := os.LookupEnv(o.contentEnv)
if !exists {
return fmt.Errorf("content environment variable %q is not set", o.contentEnv)
}
o.resolvedContent = []byte(content)
o.contentWasProvided = true
}
if o.contentFile != "" {
contentSources++
content, err := os.ReadFile(o.contentFile)
if err != nil {
return fmt.Errorf("read content file: %w", err)
}
o.resolvedContent = content
o.contentWasProvided = true
}
if o.contentStdin {
contentSources++
content, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return fmt.Errorf("read content from standard input: %w", err)
}
o.resolvedContent = content
o.contentWasProvided = true
}
if contentSources > 1 {
return errors.New("use only one of --content, --content-env, --content-file, or --content-stdin")
}
o.resolvedLiteralMatch = o.match
if o.match != "" && o.matchFile != "" {
return errors.New("use only one of --match or --match-file")
}
if o.matchFile != "" {
match, err := os.ReadFile(o.matchFile)
if err != nil {
return fmt.Errorf("read match file: %w", err)
}
o.resolvedLiteralMatch = string(match)
}
password, err := resolveCredential(o.password, o.passwordFile, "HELM_GIT_PASSWORD")
if err != nil {
return fmt.Errorf("resolve password: %w", err)
}
o.password = password
token, err := resolveCredential(o.token, o.tokenFile, "HELM_GIT_TOKEN")
if err != nil {
return fmt.Errorf("resolve token: %w", err)
}
o.token = token
passphrase, err := resolveCredential(o.sshKeyPassphrase, o.sshPassphraseFile, "HELM_GIT_SSH_KEY_PASSPHRASE")
if err != nil {
return fmt.Errorf("resolve SSH key passphrase: %w", err)
}
o.sshKeyPassphrase = passphrase
return nil
}
func (o *gitUpdateOptions) run(cmd *cobra.Command, out io.Writer) error {
var progress io.Writer
if settings.Debug {
progress = cmd.ErrOrStderr()
}
result, err := gitupdate.Update(cmd.Context(), gitupdate.RepositoryOptions{
RepositoryURL: o.repositoryURL,
Transport: o.transport,
Branch: o.branch,
BaseBranch: o.baseBranch,
CreateBranch: o.createBranch,
TargetFile: o.targetFile,
CreateFile: o.createFile,
Edit: gitupdate.EditOptions{
Method: gitupdate.Method(o.method),
Content: o.resolvedContent,
ContentProvided: o.contentWasProvided,
LiteralMatch: o.resolvedLiteralMatch,
RegexMatch: o.regex,
ExpectedMatches: o.expected,
AllMatches: o.all,
Expand: o.expand,
Pointer: o.pointer,
CreatePath: o.createPath,
},
Auth: gitupdate.AuthOptions{
Type: o.authType,
Username: o.username,
Password: o.password,
Token: o.token,
SSHPrivateKey: o.sshPrivateKey,
SSHKeyPassphrase: o.sshKeyPassphrase,
KnownHostsFile: o.sshKnownHosts,
InsecureHostKey: o.insecureSSHHostKey,
SSHAgentSocketIsSet: gitupdate.AgentAvailable(),
},
TLS: gitupdate.TLSOptions{
CAFile: o.caFile,
ClientCertificate: o.certFile,
ClientKey: o.keyFile,
InsecureSkipTLS: o.insecureSkipTLS,
},
API: gitupdate.APIOptions{
Provider: o.apiProvider,
BaseURL: o.apiBaseURL,
Project: o.apiProject,
TokenType: o.apiTokenType,
},
AuthorName: o.authorName,
AuthorEmail: o.authorEmail,
CommitMessage: o.commitMessage,
Depth: o.depth,
DryRun: o.dryRun,
Push: o.push,
Progress: progress,
})
if err != nil {
return err
}
switch {
case !result.Changed:
fmt.Fprintf(out, "No changes needed for %s on branch %s\n", result.TargetFile, result.Branch)
case o.dryRun:
fmt.Fprintf(out, "Would update %s on branch %s using %s transport (dry run; no remote commit)\n", result.TargetFile, result.Branch, result.Transport)
case result.Pushed:
fmt.Fprintf(out, "Updated %s on branch %s using %s transport in commit %s\n", result.TargetFile, result.Branch, result.Transport, result.CommitHash)
case result.Committed:
fmt.Fprintf(out, "Updated %s on branch %s in commit %s (not pushed)\n", result.TargetFile, result.Branch, result.CommitHash)
}
return nil
}
func resolveCredential(direct, filename, environment string) (string, error) {
if direct != "" && filename != "" {
return "", errors.New("a direct value and file cannot both be set")
}
if filename != "" {
value, err := os.ReadFile(filename)
if err != nil {
return "", err
}
return trimOneLineEnding(string(value)), nil
}
if direct != "" {
return direct, nil
}
return os.Getenv(environment), nil
}
func trimOneLineEnding(value string) string {
value = strings.TrimSuffix(value, "\n")
value = strings.TrimSuffix(value, "\r")
return value
}

@ -0,0 +1,116 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGitCommandIsRegistered(t *testing.T) {
root, _, err := executeActionCommand("help git")
require.NoError(t, err)
command, _, err := root.Root().Find([]string{"git", "update"})
require.NoError(t, err)
assert.Equal(t, "update", command.Name())
apiProvider := command.Flag("api-provider")
require.NotNil(t, apiProvider)
assert.Equal(t, "auto", apiProvider.DefValue)
}
func TestGitUpdateValidatesEditBeforeClone(t *testing.T) {
_, _, err := executeActionCommand("git update https://example.invalid/repo.git --branch main --file values.yaml")
require.ErrorContains(t, err, `method "overwrite" requires content`)
}
func TestGitUpdateRejectsMultipleContentSources(t *testing.T) {
contentFile := filepath.Join(t.TempDir(), "content")
require.NoError(t, os.WriteFile(contentFile, []byte("value"), 0o600))
command := newGitUpdateCmd(os.Stdout)
command.SetArgs([]string{
"https://example.invalid/repo.git",
"--branch", "main",
"--file", "values.yaml",
"--content", "inline",
"--content-file", contentFile,
})
err := command.Execute()
require.ErrorContains(t, err, "use only one of --content, --content-env, --content-file, or --content-stdin")
}
func TestGitUpdateRejectsContentAndContentEnvironment(t *testing.T) {
t.Setenv("HELM_GIT_TEST_CONTENT", "from-environment")
command := newGitUpdateCmd(os.Stdout)
command.SetArgs([]string{
"https://example.invalid/repo.git",
"--branch", "main",
"--file", "values.yaml",
"--content", "inline",
"--content-env", "HELM_GIT_TEST_CONTENT",
})
err := command.Execute()
require.ErrorContains(t, err, "use only one of --content, --content-env, --content-file, or --content-stdin")
}
func TestGitUpdateRejectsUnsetContentEnvironment(t *testing.T) {
command := newGitUpdateCmd(os.Stdout)
command.SetArgs([]string{
"https://example.invalid/repo.git",
"--branch", "main",
"--file", "values.yaml",
"--content-env", "HELM_GIT_TEST_UNSET_CONTENT",
})
err := command.Execute()
require.ErrorContains(t, err, `content environment variable "HELM_GIT_TEST_UNSET_CONTENT" is not set`)
}
func TestResolveCredential(t *testing.T) {
t.Setenv("HELM_GIT_TEST_SECRET", "from-env")
secretFile := filepath.Join(t.TempDir(), "secret")
require.NoError(t, os.WriteFile(secretFile, []byte("from-file\r\n"), 0o600))
tests := []struct {
name string
direct string
filename string
expected string
errorText string
}{
{name: "environment", expected: "from-env"},
{name: "direct", direct: "from-flag", expected: "from-flag"},
{name: "file", filename: secretFile, expected: "from-file"},
{name: "conflict", direct: "from-flag", filename: secretFile, errorText: "cannot both be set"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := resolveCredential(test.direct, test.filename, "HELM_GIT_TEST_SECRET")
if test.errorText != "" {
require.ErrorContains(t, err, test.errorText)
return
}
require.NoError(t, err)
assert.Equal(t, test.expected, actual)
})
}
}

@ -289,6 +289,7 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg
newCompletionCmd(out),
newEnvCmd(out),
newGitCmd(out),
newPluginCmd(out),
newVersionCmd(out),

Loading…
Cancel
Save