diff --git a/docs/user/reference/cli/azldev_component_build.md b/docs/user/reference/cli/azldev_component_build.md index f7ffafc..06c437d 100644 --- a/docs/user/reference/cli/azldev_component_build.md +++ b/docs/user/reference/cli/azldev_component_build.md @@ -51,6 +51,7 @@ azldev component build [flags] --preserve-buildenv policy Preserve build environment {on-failure, always, never} (default on-failure) -s, --spec-path stringArray Spec path --srpm-only Build SRPM (source RPM) *only* + --with-git Create a dist-git repository with synthetic commit history (requires a project git repository) ``` ### Options inherited from parent commands diff --git a/docs/user/reference/cli/azldev_component_prepare-sources.md b/docs/user/reference/cli/azldev_component_prepare-sources.md index 0b99f71..8810dd0 100644 --- a/docs/user/reference/cli/azldev_component_prepare-sources.md +++ b/docs/user/reference/cli/azldev_component_prepare-sources.md @@ -40,6 +40,7 @@ azldev component prepare-sources [flags] -o, --output-dir string output directory --skip-overlays skip applying overlays to prepared sources -s, --spec-path stringArray Spec path + --with-git Create a dist-git repository with synthetic commit history (requires a project git repository) ``` ### Options inherited from parent commands diff --git a/go.mod b/go.mod index b60ca3b..b989b4d 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,8 @@ require ( github.com/docker/docker v28.5.2+incompatible github.com/fatih/color v1.18.0 github.com/gkampitakis/go-snaps v0.5.21 + github.com/go-git/go-billy/v5 v5.8.0 + github.com/go-git/go-git/v5 v5.17.0 github.com/go-playground/validator/v10 v10.30.1 github.com/google/renameio v1.0.1 github.com/google/uuid v1.6.0 @@ -59,6 +61,7 @@ require ( cyphar.com/go-pathrs v0.2.4 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect @@ -72,6 +75,7 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -84,19 +88,24 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gkampitakis/ciinfo v0.3.4 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -120,6 +129,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -129,6 +139,7 @@ require ( github.com/sergi/go-diff v1.4.0 // indirect github.com/shirou/gopsutil/v4 v4.26.2 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect @@ -137,6 +148,7 @@ require ( github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect @@ -150,8 +162,10 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 5626a37..0529d58 100644 --- a/go.sum +++ b/go.sum @@ -6,12 +6,19 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +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.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/acobaugh/osrelease v0.1.0 h1:Yb59HQDGGNhCj4suHaFQQfBps5wyoKLSSX/J/+UifRE= github.com/acobaugh/osrelease v0.1.0/go.mod h1:4bFEs0MtgHNHBrmHCt67gNisnabCRAlzdVasCEGHTWY= +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/auribuo/stylishcobra v1.0.1 h1:bKGeCcLCW1Pzk5ZiT2Ubijdccn+unGrB5SBf3TQBGeA= github.com/auribuo/stylishcobra v1.0.1/go.mod h1:NRNkmflkRM1078o68o3pFpPFZeb7wtD6EscbqLZmDsU= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -69,6 +76,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -103,6 +112,10 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -120,6 +133,16 @@ github.com/gkampitakis/ciinfo v0.3.4 h1:5eBSibVuSMbb/H6Elc0IIEFbkzCJi3lm94n0+U7Z github.com/gkampitakis/ciinfo v0.3.4/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-snaps v0.5.21 h1:SvhSFeZviQXwlT+dnGyAIATVehkhqRVW6qfQZhCZH+Y= github.com/gkampitakis/go-snaps v0.5.21/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +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.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +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.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= +github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -138,6 +161,8 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0 github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU= @@ -154,10 +179,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +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/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f h1:xt29M2T6STgldg+WEP51gGePQCsQvklmP2eIhPIBK3g= @@ -229,6 +258,8 @@ github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -237,6 +268,8 @@ github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22 github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -264,8 +297,11 @@ github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKl github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.7.0/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/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -280,6 +316,7 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -307,6 +344,8 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -339,16 +378,24 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/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-20201204225414-ed752295db88/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-20210809222454-d867a43fc93e/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-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -357,12 +404,15 @@ golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= @@ -377,6 +427,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +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.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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= diff --git a/internal/app/azldev/cmds/component/build.go b/internal/app/azldev/cmds/component/build.go index d472687..f8aa76f 100644 --- a/internal/app/azldev/cmds/component/build.go +++ b/internal/app/azldev/cmds/component/build.go @@ -26,6 +26,7 @@ type ComponentBuildOptions struct { ContinueOnError bool NoCheck bool + WithGitRepo bool SourcePackageOnly bool BuildEnvPolicy BuildEnvPreservePolicy @@ -94,6 +95,8 @@ builds can consume.`, cmd.Flags().BoolVarP(&options.ContinueOnError, "continue-on-error", "k", false, "Continue building when some components fail") cmd.Flags().BoolVar(&options.NoCheck, "no-check", false, "Skip package %check tests") + cmd.Flags().BoolVar(&options.WithGitRepo, "with-git", false, + "Create a dist-git repository with synthetic commit history (requires a project git repository)") cmd.Flags().BoolVar(&options.SourcePackageOnly, "srpm-only", false, "Build SRPM (source RPM) *only*") cmd.Flags().Var(&options.BuildEnvPolicy, "preserve-buildenv", fmt.Sprintf("Preserve build environment {%s, %s, %s}", @@ -212,7 +215,12 @@ func BuildComponent( return nil }, &err) - sourcePreparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env) + var preparerOpts []sources.PreparerOption + if options.WithGitRepo { + preparerOpts = append(preparerOpts, sources.WithGitRepo()) + } + + sourcePreparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env, preparerOpts...) if err != nil { return ComponentBuildResults{}, fmt.Errorf("failed to create source preparer for component %q:\n%w", component.GetName(), err) diff --git a/internal/app/azldev/cmds/component/preparesources.go b/internal/app/azldev/cmds/component/preparesources.go index b5dfb52..6bff06b 100644 --- a/internal/app/azldev/cmds/component/preparesources.go +++ b/internal/app/azldev/cmds/component/preparesources.go @@ -20,6 +20,7 @@ type PrepareSourcesOptions struct { OutputDir string SkipOverlays bool + WithGitRepo bool Force bool } @@ -65,6 +66,8 @@ Only one component may be selected at a time.`, _ = cmd.MarkFlagDirname("output-dir") cmd.Flags().BoolVar(&options.SkipOverlays, "skip-overlays", false, "skip applying overlays to prepared sources") + cmd.Flags().BoolVar(&options.WithGitRepo, "with-git", false, + "Create a dist-git repository with synthetic commit history (requires a project git repository)") cmd.Flags().BoolVar(&options.Force, "force", false, "delete and recreate the output directory if it already exists") return cmd @@ -114,7 +117,12 @@ func PrepareComponentSources(env *azldev.Env, options *PrepareSourcesOptions) er return err } - preparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env) + var preparerOpts []sources.PreparerOption + if options.WithGitRepo { + preparerOpts = append(preparerOpts, sources.WithGitRepo()) + } + + preparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env, preparerOpts...) if err != nil { return fmt.Errorf("failed to create source preparer:\n%w", err) } diff --git a/internal/app/azldev/core/componentbuilder/componentbuilder_test.go b/internal/app/azldev/core/componentbuilder/componentbuilder_test.go index 253e9de..7fdf7ec 100644 --- a/internal/app/azldev/core/componentbuilder/componentbuilder_test.go +++ b/internal/app/azldev/core/componentbuilder/componentbuilder_test.go @@ -21,6 +21,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/buildenv" "github.com/microsoft/azure-linux-dev-tools/internal/buildenv/buildenv_testutils" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders" "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders/sourceproviders_test" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" @@ -50,8 +51,11 @@ func setupBuilder(t *testing.T) *componentBuilderTestParams { sourceManager := sourceproviders_test.NewMockSourceManager(ctrl) // Configure the source manager to create a spec file when FetchComponent is called. - sourceManager.EXPECT().FetchComponent(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( - func(ctx context.Context, component components.Component, outputDir string) error { + sourceManager.EXPECT().FetchComponent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( + func( + _ context.Context, component components.Component, + outputDir string, _ ...sourceproviders.FetchComponentOption, + ) error { // Create the expected spec file. specPath := filepath.Join(outputDir, component.GetName()+".spec") diff --git a/internal/app/azldev/core/sources/sourceprep.go b/internal/app/azldev/core/sources/sourceprep.go index 7bc4c56..79c99a7 100644 --- a/internal/app/azldev/core/sources/sourceprep.go +++ b/internal/app/azldev/core/sources/sourceprep.go @@ -7,11 +7,16 @@ import ( "context" "errors" "fmt" + "log/slog" + "os" "path/filepath" "slices" "strings" + "time" "unicode" + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" @@ -49,20 +54,40 @@ type SourcePreparer interface { DiffSources(ctx context.Context, component components.Component, baseDir string) (*dirdiff.DiffResult, error) } +// PreparerOption is a functional option for configuring a [SourcePreparer]. +type PreparerOption func(*sourcePreparerImpl) + +// WithGitRepo returns a [PreparerOption] that enables dist-git repository +// creation during source preparation. When set, the upstream .git directory +// is preserved and synthetic commit history is generated on top of it. This +// requires the project configuration to reside inside a git repository. +// Without this option, no dist-git is created and synthetic history is skipped. +func WithGitRepo() PreparerOption { + return func(p *sourcePreparerImpl) { + p.withGitRepo = true + } +} + // Standard implementation of the [SourcePreparer] interface. type sourcePreparerImpl struct { sourceManager sourceproviders.SourceManager fs opctx.FS eventListener opctx.EventListener dryRunnable opctx.DryRunnable + + // withGitRepo, when true, enables dist-git creation by preserving the + // upstream .git directory and generating synthetic commit history. + withGitRepo bool } -// NewPreparer creates a new [SourcePreparer] instance. All arguments are required. +// NewPreparer creates a new [SourcePreparer] instance. All positional arguments +// are required. Optional behavior can be configured via [PreparerOption] values. func NewPreparer( sourceManager sourceproviders.SourceManager, fs opctx.FS, eventListener opctx.EventListener, dryRunnable opctx.DryRunnable, + opts ...PreparerOption, ) (SourcePreparer, error) { if sourceManager == nil { return nil, errors.New("source manager cannot be nil") @@ -80,12 +105,20 @@ func NewPreparer( return nil, errors.New("dry runnable interface cannot be nil") } - return &sourcePreparerImpl{ + impl := &sourcePreparerImpl{ sourceManager: sourceManager, fs: fs, eventListener: eventListener, dryRunnable: dryRunnable, - }, nil + } + + for _, opt := range opts { + if opt != nil { + opt(impl) + } + } + + return impl, nil } // PrepareSources implements the [SourcePreparer] interface. @@ -99,35 +132,213 @@ func (p *sourcePreparerImpl) PrepareSources( component.GetName(), err) } + // Preserve the upstream .git directory only when dist-git creation is + // requested via --with-git. This is required so that overlay commits can be + // appended on top of the upstream commit log during synthetic history generation. + var fetchOpts []sourceproviders.FetchComponentOption + if applyOverlays && p.withGitRepo { + fetchOpts = append(fetchOpts, sourceproviders.WithPreserveGitDir()) + } + // Use the source manager to fetch the component (spec file and sidecar files). - err = p.sourceManager.FetchComponent(ctx, component, outputDir) + err = p.sourceManager.FetchComponent(ctx, component, outputDir, fetchOpts...) if err != nil { return fmt.Errorf("failed to fetch sources for component %#q:\n%w", component.GetName(), err) } if applyOverlays { - // Emit computed macros to a macros file in the output directory. - // If the build configuration produces no macros, no file is written and - // macrosFileName will be empty, signaling postProcessSources to skip - // injecting the macro load directive and Source9999 tag. - var macrosFileName string - - macrosFilePath, err := p.writeMacrosFile(component, outputDir) + err := p.applyOverlaysToSources(ctx, component, outputDir) if err != nil { - return fmt.Errorf("failed to write macros file for component %#q:\n%w", - component.GetName(), err) + return err } + } - if macrosFilePath != "" { - macrosFileName = filepath.Base(macrosFilePath) + // Record the changes as synthetic git history when dist-git creation is enabled. + if p.withGitRepo { + if err := p.trySyntheticHistory(component, outputDir); err != nil { + return fmt.Errorf("failed to generate synthetic history for component %#q:\n%w", + component.GetName(), err) } + } + + return nil +} + +// applyOverlaysToSources writes the macros file and then applies all overlays. +func (p *sourcePreparerImpl) applyOverlaysToSources( + ctx context.Context, component components.Component, outputDir string, +) error { + // Emit computed macros to a macros file in the output directory. + // If the build configuration produces no macros, no file is written and + // macrosFileName will be empty. + var macrosFileName string + + macrosFilePath, err := p.writeMacrosFile(component, outputDir) + if err != nil { + return fmt.Errorf("failed to write macros file for component %#q:\n%w", + component.GetName(), err) + } + + if macrosFilePath != "" { + macrosFileName = filepath.Base(macrosFilePath) + } + + // Apply all overlays to prepared sources. + if err := p.applyOverlays(ctx, component, outputDir, macrosFileName); err != nil { + return fmt.Errorf("failed to apply overlays for component %#q:\n%w", component.GetName(), err) + } + + return nil +} + +// applyOverlays applies all overlays (user-defined and system-generated) to the +// component sources. Overlay application is decoupled from git history generation: +// overlays modify the working tree; synthetic history is recorded separately by +// [trySyntheticHistory]. +func (p *sourcePreparerImpl) applyOverlays( + _ context.Context, component components.Component, sourcesDirPath, macrosFileName string, +) error { + event := p.eventListener.StartEvent("Applying overlays", "component", component.GetName()) + defer event.End() - // Apply any postprocessing to the sources in-place, in the output directory. - err = p.postProcessSources(component, outputDir, macrosFileName) + // Resolve the spec path once for all overlay operations in this call. + absSpecPath, err := p.resolveSpecPath(component, sourcesDirPath) + if err != nil { + return err + } + + // Collect all overlays in application order. This ensures every change is + // captured in the synthetic history, including build configuration changes. + allOverlays, err := p.collectOverlays(component, macrosFileName) + if err != nil { + return fmt.Errorf("failed to collect overlays for component %#q:\n%w", component.GetName(), err) + } + + if len(allOverlays) == 0 { + return nil + } + + // Apply all overlays to the working tree. + if err := p.applyOverlayList(allOverlays, sourcesDirPath, absSpecPath); err != nil { + return fmt.Errorf("failed to apply overlays for component %#q:\n%w", component.GetName(), err) + } + + return nil +} + +// collectOverlays gathers all overlays for a component into a single ordered slice: +// macros-load first, then user overlays, followed by check-skip and file-header overlays. +func (p *sourcePreparerImpl) collectOverlays( + component components.Component, macrosFileName string, +) ([]projectconfig.ComponentOverlay, error) { + config := component.GetConfig() + + var allOverlays []projectconfig.ComponentOverlay + + if macrosFileName != "" { + macroOverlays, err := synthesizeMacroLoadOverlays(macrosFileName) if err != nil { - return fmt.Errorf("failed to post-process sources for component %q:\n%w", component.GetName(), err) + return nil, fmt.Errorf("failed to compute macros load overlays:\n%w", err) } + + allOverlays = append(allOverlays, macroOverlays...) + } + + allOverlays = append(allOverlays, config.Overlays...) + allOverlays = append(allOverlays, synthesizeCheckSkipOverlays(config.Build.Check)...) + allOverlays = append(allOverlays, generateFileHeaderOverlay()...) + + return allOverlays, nil +} + +// initSourcesRepo initializes a new git repository in sourcesDirPath, stages all files, +// and creates an initial commit. This is used for components that don't have an upstream +// dist-git so that Affects commits can still be layered on top. +func initSourcesRepo(sourcesDirPath string) (*gogit.Repository, error) { + slog.Info("Initializing git repository for sources", "path", sourcesDirPath) + + repo, err := gogit.PlainInit(sourcesDirPath, false) + if err != nil { + return nil, fmt.Errorf("failed to initialize git repository at %#q:\n%w", sourcesDirPath, err) + } + + worktree, err := repo.Worktree() + if err != nil { + return nil, fmt.Errorf("failed to get worktree:\n%w", err) + } + + if err := worktree.AddWithOptions(&gogit.AddOptions{All: true}); err != nil { + return nil, fmt.Errorf("failed to stage files:\n%w", err) + } + + _, err = worktree.Commit("Initial sources", &gogit.CommitOptions{ + Author: &object.Signature{ + Name: "azldev", + When: time.Unix(0, 0).UTC(), + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create initial commit:\n%w", err) + } + + return repo, nil +} + +// trySyntheticHistory attempts to create synthetic git commits on top of the +// component's sources directory. If no .git directory exists, one is initialized +// with an initial commit so Affects commits can be layered on uniformly for all +// component types. +// +// Returns a non-nil error if history generation fails. +func (p *sourcePreparerImpl) trySyntheticHistory( + component components.Component, + sourcesDirPath string, +) error { + config := component.GetConfig() + + // Build commit metadata from Affects commits. + commits, err := buildSyntheticCommits(config, component.GetName()) + if err != nil { + return fmt.Errorf("failed to build synthetic commits:\n%w", err) + } + + if len(commits) == 0 { + slog.Debug("No synthetic commits to create; skipping history generation", + "component", component.GetName()) + + return nil + } + + // Check for an existing git repository in the sources directory. + // Use os.Stat rather than p.fs because go-git's PlainInit/PlainOpen always + // operate on the real OS filesystem — the check must use the same source of + // truth to avoid disagreement when p.fs is an in-memory FS (e.g. unit tests). + gitDirPath := filepath.Join(sourcesDirPath, ".git") + + _, statErr := os.Stat(gitDirPath) + + if statErr != nil && !os.IsNotExist(statErr) { + return fmt.Errorf("failed to check for .git directory at %#q:\n%w", gitDirPath, statErr) + } + + if os.IsNotExist(statErr) { + slog.Info("No .git directory in sources; initializing repository", + "component", component.GetName()) + + if _, err := initSourcesRepo(sourcesDirPath); err != nil { + return fmt.Errorf("failed to initialize sources repository:\n%w", err) + } + } + + // Open the git repository where synthetic commits will be recorded. + sourcesRepo, err := gogit.PlainOpen(sourcesDirPath) + if err != nil { + return fmt.Errorf("failed to open sources repository at %#q:\n%w", sourcesDirPath, err) + } + + if err := CommitSyntheticHistory(sourcesRepo, commits); err != nil { + return fmt.Errorf("failed to commit synthetic history:\n%w", err) } return nil @@ -172,19 +383,8 @@ func (p *sourcePreparerImpl) DiffSources( } // Apply overlays in-place to the copied directory only. - var macrosFileName string - - macrosFilePath, err := p.writeMacrosFile(component, overlaidDir) - if err != nil { - return nil, fmt.Errorf("failed to write macros file for component %#q:\n%w", component.GetName(), err) - } - - if macrosFilePath != "" { - macrosFileName = filepath.Base(macrosFilePath) - } - - if err := p.postProcessSources(component, overlaidDir, macrosFileName); err != nil { - return nil, fmt.Errorf("failed to post-process sources for component %#q:\n%w", component.GetName(), err) + if err := p.applyOverlaysToSources(ctx, component, overlaidDir); err != nil { + return nil, fmt.Errorf("failed to apply overlays for component %#q:\n%w", component.GetName(), err) } // Diff the original tree against the overlaid tree. @@ -286,74 +486,6 @@ func GenerateMacrosFileContents(buildConfig projectconfig.ComponentBuildConfig) return strings.Join(lines, "\n") + "\n" } -func (p *sourcePreparerImpl) postProcessSources( - component components.Component, sourcesDirPath, macrosFileName string, -) error { - event := p.eventListener.StartEvent("Applying overlays to sources", "component", component.GetName()) - defer event.End() - - // Find the spec. - specPath, err := findSpecInDir(p.fs, component, sourcesDirPath) - if err != nil { - return fmt.Errorf("failed to find spec in acquired sources dir %#q:\n%w", sourcesDirPath, err) - } - - // Get an absolute path to the spec for better errors. - absSpecPath, err := filepath.Abs(specPath) - if err != nil { - return fmt.Errorf("failed to get absolute path for %#q:\n%w", specPath, err) - } - - // Compute any synthetic overlays required to load the macros file, if one was written. - if macrosFileName != "" { - macroOverlays, macroErr := synthesizeMacroLoadOverlays(macrosFileName) - if macroErr != nil { - return fmt.Errorf("failed to compute macros load overlays:\n%w", macroErr) - } - - // Apply those overlays *first*, in sequence. - for _, overlay := range macroOverlays { - err = ApplyOverlayToSources(p.dryRunnable, p.fs, overlay, sourcesDirPath, absSpecPath) - if err != nil { - return fmt.Errorf("failed to apply system overlay to sources for component %#q:\n%w", component.GetName(), err) - } - } - } - - // Get the file header overlay. - headerOverlay := generateFileHeaderOverlay() - - // Apply all overlays in sequence. - for _, overlay := range component.GetConfig().Overlays { - err = ApplyOverlayToSources(p.dryRunnable, p.fs, overlay, sourcesDirPath, absSpecPath) - if err != nil { - return fmt.Errorf( - "failed to apply %#q overlay to sources for component %#q:\n%w", - overlay.Type, component.GetName(), err, - ) - } - } - - // Apply check skip overlay if configured. - checkSkipOverlays := synthesizeCheckSkipOverlays(component.GetConfig().Build.Check) - for _, overlay := range checkSkipOverlays { - err = ApplyOverlayToSources(p.dryRunnable, p.fs, overlay, sourcesDirPath, absSpecPath) - if err != nil { - return fmt.Errorf("failed to apply check skip overlay to sources for component %#q:\n%w", component.GetName(), err) - } - } - - // Finally, apply the file header overlay. - for _, overlay := range headerOverlay { - err = ApplyOverlayToSources(p.dryRunnable, p.fs, overlay, sourcesDirPath, absSpecPath) - if err != nil { - return fmt.Errorf("failed to apply file header overlay to sources for component %#q:\n%w", component.GetName(), err) - } - } - - return nil -} - func synthesizeMacroLoadOverlays(macrosFileName string) ([]projectconfig.ComponentOverlay, error) { // Basic check that the macros file name is valid and doesn't require escaping. if strings.ContainsFunc(macrosFileName, func(r rune) bool { @@ -427,6 +559,39 @@ func synthesizeCheckSkipOverlays(checkConfig projectconfig.CheckConfig) []projec } } +// resolveSpecPath locates and returns the absolute path to the component's spec file +// within the given sources directory. +func (p *sourcePreparerImpl) resolveSpecPath( + component components.Component, sourcesDirPath string, +) (string, error) { + specPath, err := findSpecInDir(p.fs, component, sourcesDirPath) + if err != nil { + return "", fmt.Errorf("failed to find spec in sources dir %#q:\n%w", sourcesDirPath, err) + } + + absSpecPath, err := filepath.Abs(specPath) + if err != nil { + return "", fmt.Errorf("failed to get absolute path for spec %#q:\n%w", specPath, err) + } + + return absSpecPath, nil +} + +// applyOverlayList applies a list of overlays to the component sources sequentially. +func (p *sourcePreparerImpl) applyOverlayList( + overlays []projectconfig.ComponentOverlay, sourcesDirPath, absSpecPath string, +) error { + for _, overlay := range overlays { + if err := ApplyOverlayToSources( + p.dryRunnable, p.fs, overlay, sourcesDirPath, absSpecPath, + ); err != nil { + return fmt.Errorf("failed to apply %#q overlay:\n%w", overlay.Type, err) + } + } + + return nil +} + func findSpecInDir( fs opctx.FS, component components.Component, dirPath string, ) (string, error) { diff --git a/internal/app/azldev/core/sources/sourceprep_test.go b/internal/app/azldev/core/sources/sourceprep_test.go index 4e5f2f1..edb4bf1 100644 --- a/internal/app/azldev/core/sources/sourceprep_test.go +++ b/internal/app/azldev/core/sources/sourceprep_test.go @@ -13,6 +13,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources" "github.com/microsoft/azure-linux-dev-tools/internal/global/testctx" "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders" "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders/sourceproviders_test" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" "github.com/stretchr/testify/assert" @@ -56,8 +57,8 @@ func TestPrepareSources_Success(t *testing.T) { component.EXPECT().GetName().AnyTimes().Return("test-component") component.EXPECT().GetConfig().AnyTimes().Return(&projectconfig.ComponentConfig{}) sourceManager.EXPECT().FetchFiles(gomock.Any(), component, testOutputDir).Return(nil) - sourceManager.EXPECT().FetchComponent(gomock.Any(), component, testOutputDir).DoAndReturn( - func(_ interface{}, _ interface{}, outputDir string) error { + sourceManager.EXPECT().FetchComponent(gomock.Any(), component, testOutputDir, gomock.Any()).DoAndReturn( + func(_ interface{}, _ interface{}, outputDir string, _ ...sourceproviders.FetchComponentOption) error { // Create the expected spec file. return fileutils.WriteFile(ctx.FS(), outputSpecPath, []byte("# test spec"), 0o644) }, @@ -115,8 +116,8 @@ func TestPrepareSources_WritesMacrosFile(t *testing.T) { }, }) sourceManager.EXPECT().FetchFiles(gomock.Any(), component, testOutputDir).Return(nil) - sourceManager.EXPECT().FetchComponent(gomock.Any(), component, testOutputDir).DoAndReturn( - func(_ interface{}, _ interface{}, outputDir string) error { + sourceManager.EXPECT().FetchComponent(gomock.Any(), component, testOutputDir, gomock.Any()).DoAndReturn( + func(_ interface{}, _ interface{}, outputDir string, _ ...sourceproviders.FetchComponentOption) error { // Create the expected spec file. specPath := filepath.Join(outputDir, "my-package.spec") @@ -365,8 +366,8 @@ func TestPrepareSources_CheckSkip(t *testing.T) { }, }) sourceManager.EXPECT().FetchFiles(gomock.Any(), component, testOutputDir).Return(nil) - sourceManager.EXPECT().FetchComponent(gomock.Any(), component, testOutputDir).DoAndReturn( - func(_ interface{}, _ interface{}, outputDir string) error { + sourceManager.EXPECT().FetchComponent(gomock.Any(), component, testOutputDir, gomock.Any()).DoAndReturn( + func(_ interface{}, _ interface{}, outputDir string, _ ...sourceproviders.FetchComponentOption) error { // Create the expected spec file with a %check section. specContent := `Name: test-component Version: 1.0 @@ -422,8 +423,8 @@ func TestPrepareSources_CheckSkipDisabled(t *testing.T) { }, }) sourceManager.EXPECT().FetchFiles(gomock.Any(), component, testOutputDir).Return(nil) - sourceManager.EXPECT().FetchComponent(gomock.Any(), component, testOutputDir).DoAndReturn( - func(_ interface{}, _ interface{}, outputDir string) error { + sourceManager.EXPECT().FetchComponent(gomock.Any(), component, testOutputDir, gomock.Any()).DoAndReturn( + func(_ interface{}, _ interface{}, outputDir string, _ ...sourceproviders.FetchComponentOption) error { // Create the expected spec file with a %check section. specContent := `Name: test-component Version: 1.0 @@ -469,7 +470,7 @@ func TestDiffSources_NoOverlays(t *testing.T) { // DiffSources fetches sources once, then copies them for overlay application. sourceManager.EXPECT().FetchFiles(gomock.Any(), component, gomock.Any()).Times(1).Return(nil) sourceManager.EXPECT().FetchComponent(gomock.Any(), component, gomock.Any()).Times(1).DoAndReturn( - func(_ interface{}, _ interface{}, outputDir string) error { + func(_ interface{}, _ interface{}, outputDir string, _ ...sourceproviders.FetchComponentOption) error { specPath := filepath.Join(outputDir, "test-component.spec") return fileutils.WriteFile(ctx.FS(), specPath, []byte("Name: test-component\nVersion: 1.0\n"), 0o644) @@ -512,7 +513,7 @@ func TestDiffSources_WithOverlays(t *testing.T) { // DiffSources fetches sources once, then copies them for overlay application. sourceManager.EXPECT().FetchFiles(gomock.Any(), component, gomock.Any()).Times(1).Return(nil) sourceManager.EXPECT().FetchComponent(gomock.Any(), component, gomock.Any()).Times(1).DoAndReturn( - func(_ interface{}, _ interface{}, outputDir string) error { + func(_ interface{}, _ interface{}, outputDir string, _ ...sourceproviders.FetchComponentOption) error { specPath := filepath.Join(outputDir, "test-component.spec") return fileutils.WriteFile(ctx.FS(), specPath, []byte("Name: test-component\nVersion: 1.0\n"), 0o644) diff --git a/internal/app/azldev/core/sources/synthistory.go b/internal/app/azldev/core/sources/synthistory.go new file mode 100644 index 0000000..0c41f2e --- /dev/null +++ b/internal/app/azldev/core/sources/synthistory.go @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package sources + +import ( + "fmt" + "log/slog" + "path/filepath" + "regexp" + "slices" + "strings" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" +) + +// affectsRegexPattern is the regex pattern prefix used to match an "Affects:" trailer +// line in a commit message. Each line must contain exactly one component name. +const affectsRegexPattern = `(?m)^[ \t]*Affects:[ \t]*` + +// CommitMetadata holds full metadata for a commit in the project repository. +type CommitMetadata struct { + Hash string + Author string + AuthorEmail string + Timestamp int64 + Message string +} + +// MessageAffectsComponent reports whether a commit message contains an "Affects:" +// trailer line naming the given component. +func MessageAffectsComponent(message, componentName string) bool { + re := regexp.MustCompile(affectsRegexPattern + regexp.QuoteMeta(componentName) + `[ \t]*$`) + + return re.MatchString(message) +} + +// FindAffectsCommits walks the git log from HEAD and returns metadata for all commits +// whose message contains an "Affects: " trailer line. Results are sorted +// chronologically (oldest first). +func FindAffectsCommits(repo *gogit.Repository, componentName string) ([]CommitMetadata, error) { + head, err := repo.Head() + if err != nil { + return nil, fmt.Errorf("failed to get HEAD reference:\n%w", err) + } + + commitIter, err := repo.Log(&gogit.LogOptions{From: head.Hash()}) + if err != nil { + return nil, fmt.Errorf("failed to iterate commit log:\n%w", err) + } + + var matches []CommitMetadata + + err = commitIter.ForEach(func(commit *object.Commit) error { + if MessageAffectsComponent(commit.Message, componentName) { + matches = append(matches, CommitMetadata{ + Hash: commit.Hash.String(), + Author: commit.Author.Name, + AuthorEmail: commit.Author.Email, + Timestamp: commit.Author.When.Unix(), + Message: strings.TrimSpace(commit.Message), + }) + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to walk commit log:\n%w", err) + } + + // Log iteration returns newest-first; reverse to get chronological order. + slices.Reverse(matches) + + return matches, nil +} + +// CommitSyntheticHistory stages all pending working tree changes and creates synthetic +// commits in the provided git repository. The first commit captures all file changes; +// subsequent commits are created as empty commits to preserve the commit count for +// rpmautospec release numbering. +func CommitSyntheticHistory( + repo *gogit.Repository, + commits []CommitMetadata, +) error { + worktree, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree:\n%w", err) + } + + // Stage all working tree changes once — overlays have already been applied. + if err := worktree.AddWithOptions(&gogit.AddOptions{All: true}); err != nil { + return fmt.Errorf("failed to stage changes:\n%w", err) + } + + for commitIdx, commitMeta := range commits { + slog.Info("Creating synthetic commit", + "commit", commitIdx+1, + "total", len(commits), + "projectHash", commitMeta.Hash, + ) + + message := fmt.Sprintf("%s\n\nProject commit: %s", + commitMeta.Message, commitMeta.Hash) + + _, err := worktree.Commit(message, &gogit.CommitOptions{ + AllowEmptyCommits: true, + Author: &object.Signature{ + Name: commitMeta.Author, + Email: commitMeta.AuthorEmail, + When: unixToTime(commitMeta.Timestamp), + }, + }) + if err != nil { + return fmt.Errorf("failed to create synthetic commit %d:\n%w", commitIdx+1, err) + } + } + + slog.Info("Synthetic history generation complete", + "commitsCreated", len(commits)) + + return nil +} + +// buildSyntheticCommits resolves the project repository from the component's config file, +// walks the git log for commits containing "Affects: ", and returns the +// matching commit metadata sorted chronologically. If no Affects commits are found, a +// single default overlay commit is returned instead. +func buildSyntheticCommits( + config *projectconfig.ComponentConfig, componentName string, +) ([]CommitMetadata, error) { + configFilePath, err := resolveConfigFilePath(config, componentName) + if err != nil { + // No config file reference means this component can't have Affects commits. + slog.Debug("Cannot resolve config file for synthetic commits; skipping", + "component", componentName, "error", err) + + return nil, nil + } + + projectRepo, err := openProjectRepo(configFilePath) + if err != nil { + return nil, err + } + + affectsCommits, err := FindAffectsCommits(projectRepo, componentName) + if err != nil { + return nil, fmt.Errorf("failed to find Affects commits for component %#q:\n%w", componentName, err) + } + + slog.Info("Found commits affecting component", + "component", componentName, + "commitCount", len(affectsCommits)) + + if len(affectsCommits) == 0 { + slog.Info("No commits with Affects marker found; "+ + "creating default commit", + "component", componentName) + + return []CommitMetadata{ + defaultOverlayCommit(projectRepo, componentName), + }, nil + } + + return affectsCommits, nil +} + +// defaultOverlayCommit returns a single [CommitMetadata] entry that represents a generic +// commit when no Affects commits exist in the project history. The commit hash is +// set to the current HEAD of the project repository. +func defaultOverlayCommit(repo *gogit.Repository, componentName string) CommitMetadata { + var ( + timestamp int64 + hash string + ) + + if head, err := repo.Head(); err == nil { + hash = head.Hash().String() + if commit, commitErr := repo.CommitObject(head.Hash()); commitErr == nil { + timestamp = commit.Author.When.Unix() + } + } + + return CommitMetadata{ + Hash: hash, + Author: "azldev", + Timestamp: timestamp, + Message: "Latest state for " + componentName, + } +} + +// resolveConfigFilePath extracts and validates the source config file path from the component config. +func resolveConfigFilePath(config *projectconfig.ComponentConfig, componentName string) (string, error) { + configFile := config.SourceConfigFile + if configFile == nil { + return "", fmt.Errorf("component %#q has no source config file reference", componentName) + } + + configFilePath := configFile.SourcePath() + if configFilePath == "" { + return "", fmt.Errorf("component %#q source config file has no path", componentName) + } + + return configFilePath, nil +} + +// openProjectRepo finds and opens the git repository containing configFilePath by +// walking up the directory tree. +func openProjectRepo(configFilePath string) (*gogit.Repository, error) { + repo, err := gogit.PlainOpenWithOptions(filepath.Dir(configFilePath), &gogit.PlainOpenOptions{ + DetectDotGit: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to find project repository for config file %#q:\n%w", + configFilePath, err) + } + + return repo, nil +} + +// unixToTime converts a Unix timestamp to a [time.Time] in UTC. +func unixToTime(unix int64) time.Time { + return time.Unix(unix, 0).UTC() +} diff --git a/internal/app/azldev/core/sources/synthistory_test.go b/internal/app/azldev/core/sources/synthistory_test.go new file mode 100644 index 0000000..24ef820 --- /dev/null +++ b/internal/app/azldev/core/sources/synthistory_test.go @@ -0,0 +1,410 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package sources_test + +import ( + "fmt" + "testing" + "time" + + memfs "github.com/go-git/go-billy/v5/memfs" + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/storage/memory" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createInMemoryRepo creates an empty in-memory git repository. +func createInMemoryRepo(t *testing.T) *gogit.Repository { + t.Helper() + + repo, err := gogit.Init(memory.NewStorage(), memfs.New()) + require.NoError(t, err) + + return repo +} + +// addCommit creates a commit in the in-memory repository with the given message, author name, +// email, and timestamp. A dummy file change is added to ensure the commit is non-empty. +func addCommit( + t *testing.T, repo *gogit.Repository, message, authorName, authorEmail string, when time.Time, +) { + t.Helper() + + worktree, err := repo.Worktree() + require.NoError(t, err) + + fs := worktree.Filesystem + + // Write a unique file per commit to guarantee a non-empty diff. + fileName := fmt.Sprintf("file-%d.txt", when.UnixNano()) + + f, err := fs.Create(fileName) + require.NoError(t, err) + + _, err = f.Write([]byte(message)) + require.NoError(t, err) + require.NoError(t, f.Close()) + + _, err = worktree.Add(fileName) + require.NoError(t, err) + + _, err = worktree.Commit(message, &gogit.CommitOptions{ + Author: &object.Signature{ + Name: authorName, + Email: authorEmail, + When: when, + }, + }) + require.NoError(t, err) +} + +func TestFindAffectsCommits(t *testing.T) { + repo := createInMemoryRepo(t) + + // Three commits: two mention curl, one does not. + addCommit(t, repo, + "Initial setup", + "Alice", "alice@example.com", + time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC)) + + addCommit(t, repo, + "Fix CVE-2025-1234\n\nAffects: curl", + "Bob", "bob@example.com", + time.Date(2025, 2, 1, 10, 0, 0, 0, time.UTC)) + + addCommit(t, repo, + "Bump release\n\nAffects: curl", + "Charlie", "charlie@example.com", + time.Date(2025, 3, 1, 10, 0, 0, 0, time.UTC)) + + results, err := sources.FindAffectsCommits(repo, "curl") + require.NoError(t, err) + + // Expect 2 matching commits, oldest first. + require.Len(t, results, 2) + + assert.Equal(t, "Bob", results[0].Author) + assert.Equal(t, "bob@example.com", results[0].AuthorEmail) + assert.Contains(t, results[0].Message, "Fix CVE-2025-1234") + + assert.Equal(t, "Charlie", results[1].Author) + assert.Equal(t, "charlie@example.com", results[1].AuthorEmail) + assert.Contains(t, results[1].Message, "Bump release") + + // Chronological order: Bob's timestamp < Charlie's timestamp. + assert.Less(t, results[0].Timestamp, results[1].Timestamp) +} + +func TestFindAffectsCommits_NoMatches(t *testing.T) { + repo := createInMemoryRepo(t) + + addCommit(t, repo, + "Unrelated change", + "Alice", "alice@example.com", + time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC)) + + results, err := sources.FindAffectsCommits(repo, "curl") + require.NoError(t, err) + assert.Empty(t, results) +} + +func TestFindAffectsCommits_MultipleComponents(t *testing.T) { + repo := createInMemoryRepo(t) + + addCommit(t, repo, + "Fix curl issue\n\nAffects: curl", + "Alice", "alice@example.com", + time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC)) + + addCommit(t, repo, + "Fix wget issue\n\nAffects: wget", + "Bob", "bob@example.com", + time.Date(2025, 2, 1, 10, 0, 0, 0, time.UTC)) + + addCommit(t, repo, + "Fix both\n\nAffects: curl\nAffects: wget", + "Charlie", "charlie@example.com", + time.Date(2025, 3, 1, 10, 0, 0, 0, time.UTC)) + + curlResults, err := sources.FindAffectsCommits(repo, "curl") + require.NoError(t, err) + require.Len(t, curlResults, 2, "curl should match 2 commits") + assert.Equal(t, "Alice", curlResults[0].Author) + assert.Equal(t, "Charlie", curlResults[1].Author) + + wgetResults, err := sources.FindAffectsCommits(repo, "wget") + require.NoError(t, err) + require.Len(t, wgetResults, 2, "wget should match 2 commits") + assert.Equal(t, "Bob", wgetResults[0].Author) + assert.Equal(t, "Charlie", wgetResults[1].Author) +} + +func TestFindAffectsCommits_NoSubstringMatch(t *testing.T) { + repo := createInMemoryRepo(t) + + // "Affects: curl-minimal" should NOT match when searching for "curl". + addCommit(t, repo, + "Update curl-minimal\n\nAffects: curl-minimal", + "Alice", "alice@example.com", + time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC)) + + addCommit(t, repo, + "Update curl itself\n\nAffects: curl", + "Bob", "bob@example.com", + time.Date(2025, 2, 1, 10, 0, 0, 0, time.UTC)) + + // Searching for "curl" matches only Bob's commit (exact component name). + curlResults, err := sources.FindAffectsCommits(repo, "curl") + require.NoError(t, err) + require.Len(t, curlResults, 1, "exact match should not include curl-minimal commit") + assert.Equal(t, "Bob", curlResults[0].Author) + + // Searching for "curl-minimal" matches only Alice's commit. + minimalResults, err := sources.FindAffectsCommits(repo, "curl-minimal") + require.NoError(t, err) + require.Len(t, minimalResults, 1) + assert.Equal(t, "Alice", minimalResults[0].Author) +} + +func TestFindAffectsCommits_AffectsInSubject(t *testing.T) { + repo := createInMemoryRepo(t) + + // Affects marker in the subject line (not just the body). + addCommit(t, repo, + "Affects: curl", + "Alice", "alice@example.com", + time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC)) + + results, err := sources.FindAffectsCommits(repo, "curl") + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "Alice", results[0].Author) +} + +func TestFindAffectsCommits_CaseSensitive(t *testing.T) { + repo := createInMemoryRepo(t) + + addCommit(t, repo, + "Bump release\n\nAffects: Kernel", + "Alice", "alice@example.com", + time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC)) + + addCommit(t, repo, + "Fix CVE\n\nAFFECTS: KERNEL", + "Bob", "bob@example.com", + time.Date(2025, 2, 1, 10, 0, 0, 0, time.UTC)) + + addCommit(t, repo, + "Upstream fix\n\nAffects: kernel", + "Charlie", "charlie@example.com", + time.Date(2025, 3, 1, 10, 0, 0, 0, time.UTC)) + + // Matching is case-sensitive: searching for "kernel" only matches the exact-case commit. + results, err := sources.FindAffectsCommits(repo, "kernel") + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "Charlie", results[0].Author) + + // Searching for "Kernel" matches only Alice's commit (exact case on component name). + results, err = sources.FindAffectsCommits(repo, "Kernel") + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "Alice", results[0].Author) +} + +func TestMessageAffectsComponent(t *testing.T) { + tests := []struct { + name string + message string + component string + want bool + }{ + // Positive matches. + {"exact match in body", "Fix bug\n\nAffects: curl", "curl", true}, + {"trailing whitespace", "Fix bug\n\nAffects: curl ", "curl", true}, + {"leading whitespace on line", "Fix bug\n\n Affects: curl", "curl", true}, + {"subject line only", "Affects: curl", "curl", true}, + + // Negative matches. + {"different component", "Fix bug\n\nAffects: wget", "curl", false}, + {"no substring match", "Fix bug\n\nAffects: curl-minimal", "curl", false}, + {"comma separated", "Fix bug\n\nAffects: curl, wget", "curl", false}, + {"extra text after name", "Affects: curl - fix build failure", "curl", false}, + {"case sensitive", "Fix bug\n\nAffects: Curl", "curl", false}, + {"no match across newlines", "Fix bug\n\nAffects:\ncurl", "curl", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sources.MessageAffectsComponent(tt.message, tt.component) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCommitSyntheticHistory(t *testing.T) { + // Create an in-memory repo with an initial commit (simulating upstream). + memFS := memfs.New() + storer := memory.NewStorage() + + repo, err := gogit.Init(storer, memFS) + require.NoError(t, err) + + worktree, err := repo.Worktree() + require.NoError(t, err) + + // Create an initial file (upstream). + file, err := memFS.Create("package.spec") + require.NoError(t, err) + + _, err = file.Write([]byte("Name: package\nVersion: 1.0\n")) + require.NoError(t, err) + require.NoError(t, file.Close()) + + _, err = worktree.Add("package.spec") + require.NoError(t, err) + + _, err = worktree.Commit("upstream: initial", &gogit.CommitOptions{ + Author: &object.Signature{ + Name: "Upstream", + Email: "upstream@fedora.org", + When: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), + }, + }) + require.NoError(t, err) + + // Simulate overlay application by modifying the working tree before committing. + specFile, err := memFS.Create("package.spec") + require.NoError(t, err) + + _, err = specFile.Write([]byte("Name: package\nVersion: 1.0\n# overlays applied\n")) + require.NoError(t, err) + require.NoError(t, specFile.Close()) + + // Define synthetic commits. + commits := []sources.CommitMetadata{ + { + Hash: "abc123def456", + Author: "Alice", + AuthorEmail: "alice@example.com", + Timestamp: time.Date(2025, 1, 10, 10, 0, 0, 0, time.UTC).Unix(), + Message: "Apply patch fix", + }, + { + Hash: "789abc012def", + Author: "Bob", + AuthorEmail: "bob@example.com", + Timestamp: time.Date(2025, 2, 20, 14, 0, 0, 0, time.UTC).Unix(), + Message: "Bump release", + }, + } + + err = sources.CommitSyntheticHistory(repo, commits) + require.NoError(t, err) + + // Verify the commit log has 3 commits: upstream + 2 synthetic. + head, err := repo.Head() + require.NoError(t, err) + + commitIter, err := repo.Log(&gogit.LogOptions{From: head.Hash()}) + require.NoError(t, err) + + var logCommits []*object.Commit + + err = commitIter.ForEach(func(c *object.Commit) error { + logCommits = append(logCommits, c) + + return nil + }) + require.NoError(t, err) + + require.Len(t, logCommits, 3, "should have upstream + 2 synthetic commits") + + // Most recent commit (Bob's) — empty commit. + assert.Contains(t, logCommits[0].Message, "Bump release") + assert.Equal(t, "Bob", logCommits[0].Author.Name) + assert.Equal(t, "bob@example.com", logCommits[0].Author.Email) + + // Second commit (Alice's) — has the actual file changes. + assert.Contains(t, logCommits[1].Message, "Apply patch fix") + assert.Equal(t, "Alice", logCommits[1].Author.Name) + + // Original upstream commit. + assert.Equal(t, "upstream: initial", logCommits[2].Message) +} + +func TestCommitSyntheticHistory_SingleCommit(t *testing.T) { + memFS := memfs.New() + storer := memory.NewStorage() + + repo, err := gogit.Init(storer, memFS) + require.NoError(t, err) + + worktree, err := repo.Worktree() + require.NoError(t, err) + + file, err := memFS.Create("package.spec") + require.NoError(t, err) + + _, err = file.Write([]byte("Name: package\n")) + require.NoError(t, err) + require.NoError(t, file.Close()) + + _, err = worktree.Add("package.spec") + require.NoError(t, err) + + _, err = worktree.Commit("upstream: initial", &gogit.CommitOptions{ + Author: &object.Signature{ + Name: "Upstream", + Email: "upstream@fedora.org", + When: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), + }, + }) + require.NoError(t, err) + + // Modify working tree (simulates overlay application). + specFile, err := memFS.Create("package.spec") + require.NoError(t, err) + + _, err = specFile.Write([]byte("Name: package\n# modified\n")) + require.NoError(t, err) + require.NoError(t, specFile.Close()) + + commits := []sources.CommitMetadata{ + { + Hash: "abc123", + Author: "Alice", + AuthorEmail: "alice@example.com", + Timestamp: time.Date(2025, 1, 10, 10, 0, 0, 0, time.UTC).Unix(), + Message: "Fix build", + }, + } + + err = sources.CommitSyntheticHistory(repo, commits) + require.NoError(t, err) + + // Verify working tree changes are in the single synthetic commit. + head, err := repo.Head() + require.NoError(t, err) + + headCommit, err := repo.CommitObject(head.Hash()) + require.NoError(t, err) + + assert.Contains(t, headCommit.Message, "Fix build") + assert.Equal(t, "Alice", headCommit.Author.Name) + + // Verify file content was committed. + tree, err := headCommit.Tree() + require.NoError(t, err) + + entry, err := tree.File("package.spec") + require.NoError(t, err) + + content, err := entry.Contents() + require.NoError(t, err) + assert.Contains(t, content, "# modified") +} diff --git a/internal/projectconfig/configfile.go b/internal/projectconfig/configfile.go index 9274472..1c15346 100644 --- a/internal/projectconfig/configfile.go +++ b/internal/projectconfig/configfile.go @@ -49,6 +49,17 @@ type ConfigFile struct { dir string `toml:"-" validate:"dir"` } +// SourcePath returns the absolute path to the config file on disk. +func (f ConfigFile) SourcePath() string { + return f.sourcePath +} + +// Dir returns the directory containing the config file; relative paths within the config +// are resolved against this directory. +func (f ConfigFile) Dir() string { + return f.dir +} + // Validates the format and internal consistency of the config file. Semantic errors are reported. func (f ConfigFile) Validate() error { err := validator.New().Struct(f) diff --git a/internal/providers/sourceproviders/fedorasourceprovider.go b/internal/providers/sourceproviders/fedorasourceprovider.go index 9dddbc4..d86117f 100644 --- a/internal/providers/sourceproviders/fedorasourceprovider.go +++ b/internal/providers/sourceproviders/fedorasourceprovider.go @@ -85,8 +85,10 @@ func NewFedoraSourcesProviderImpl( } func (g *FedoraSourcesProviderImpl) GetComponent( - ctx context.Context, component components.Component, destDirPath string, + ctx context.Context, component components.Component, destDirPath string, opts ...FetchComponentOption, ) (err error) { + resolved := resolveFetchComponentOptions(opts) + componentName := component.GetName() if componentName == "" { return errors.New("component name cannot be empty") @@ -144,7 +146,7 @@ func (g *FedoraSourcesProviderImpl) GetComponent( // Process the cloned repo: checkout target commit, extract sources, copy to destination. return g.processClonedRepo(ctx, component.GetConfig().Spec.UpstreamCommit, - tempDir, upstreamNameToUse, componentName, destDirPath, skipFileNames) + tempDir, upstreamNameToUse, componentName, destDirPath, skipFileNames, resolved) } // processClonedRepo handles the post-clone steps: checking out the target commit, @@ -154,16 +156,20 @@ func (g *FedoraSourcesProviderImpl) processClonedRepo( upstreamCommit string, tempDir, upstreamName, componentName, destDirPath string, skipFilenames []string, + opts FetchComponentOptions, ) error { // Checkout the appropriate commit based on component/distro config if err := g.checkoutTargetCommit(ctx, upstreamCommit, tempDir); err != nil { return fmt.Errorf("failed to checkout target commit:\n%w", err) } - // Delete the .git directory so it's not copied to destination. - if err := g.fs.RemoveAll(filepath.Join(tempDir, ".git")); err != nil { - return fmt.Errorf("failed to remove .git directory from cloned repository at %#q:\n%w", - tempDir, err) + // Delete the .git directory so it's not copied to destination, unless the caller + // requested that it be preserved (e.g., for synthetic history generation). + if !opts.PreserveGitDir { + if err := g.fs.RemoveAll(filepath.Join(tempDir, ".git")); err != nil { + return fmt.Errorf("failed to remove .git directory from cloned repository at %#q:\n%w", + tempDir, err) + } } // Extract sources from repo (downloads lookaside files into the temp dir). diff --git a/internal/providers/sourceproviders/rpmcontentsprovider.go b/internal/providers/sourceproviders/rpmcontentsprovider.go index 4240d5f..a02525c 100644 --- a/internal/providers/sourceproviders/rpmcontentsprovider.go +++ b/internal/providers/sourceproviders/rpmcontentsprovider.go @@ -46,7 +46,7 @@ func NewRPMContentsProviderImpl( // GetComponent downloads the source RPM for a component and extracts its contents // in the provided destination path. func (r *RPMContentsProviderImpl) GetComponent( - ctx context.Context, component components.Component, destDirPath string, + ctx context.Context, component components.Component, destDirPath string, _ ...FetchComponentOption, ) (err error) { if component.GetName() == "" { return errors.New("component name cannot be empty") diff --git a/internal/providers/sourceproviders/sourcemanager.go b/internal/providers/sourceproviders/sourcemanager.go index 8bb54c8..51fe503 100644 --- a/internal/providers/sourceproviders/sourcemanager.go +++ b/internal/providers/sourceproviders/sourcemanager.go @@ -37,6 +37,40 @@ type FileSourceProvider interface { GetFiles(ctx context.Context, fileRefs []projectconfig.SourceFileReference, destDirPath string) error } +// FetchComponentOptions holds optional parameters for component fetching operations. +type FetchComponentOptions struct { + // PreserveGitDir, when true, instructs the provider to keep the upstream .git directory + // in the fetched component sources instead of deleting it. This is required for building + // synthetic git history from overlay blame metadata. + PreserveGitDir bool +} + +// FetchComponentOption is a functional option for configuring component fetch behavior. +type FetchComponentOption func(*FetchComponentOptions) + +// WithPreserveGitDir returns a [FetchComponentOption] that instructs the provider to preserve +// the upstream .git directory in the fetched component sources. +func WithPreserveGitDir() FetchComponentOption { + return func(o *FetchComponentOptions) { + o.PreserveGitDir = true + } +} + +// resolveFetchComponentOptions applies all functional options and returns the resolved options. +func resolveFetchComponentOptions(opts []FetchComponentOption) FetchComponentOptions { + var resolved FetchComponentOptions + + for _, opt := range opts { + if opt == nil { + continue + } + + opt(&resolved) + } + + return resolved +} + // ComponentSourceProvider is an abstract interface implemented by a source provider that can retrieve the // full file contents of a given component. type ComponentSourceProvider interface { @@ -44,7 +78,10 @@ type ComponentSourceProvider interface { // GetComponent retrieves the `.spec` for the specified component along with any sidecar // files stored along with it, placing the fetched files in the provided directory. - GetComponent(ctx context.Context, component components.Component, destDirPath string) error + GetComponent( + ctx context.Context, component components.Component, destDirPath string, + opts ...FetchComponentOption, + ) error } // SourceManager is an abstract interface for a facility that can fetch arbitrary component sources. @@ -53,7 +90,12 @@ type SourceManager interface { FetchFiles(ctx context.Context, component components.Component, destDirPath string) error // FetchComponent fetches an entire upstream component, including its `.spec` file and any sidecar files. - FetchComponent(ctx context.Context, component components.Component, destDirPath string) error + // Optional [FetchComponentOption] values may be passed to control provider behavior (e.g., preserving + // the upstream .git directory). + FetchComponent( + ctx context.Context, component components.Component, destDirPath string, + opts ...FetchComponentOption, + ) error } // ResolvedDistro holds the fully resolved distro configuration for a component. @@ -380,7 +422,9 @@ func resolvePackageName(component components.Component) string { return component.GetName() } -func (m *sourceManager) FetchComponent(ctx context.Context, component components.Component, destDirPath string) error { +func (m *sourceManager) FetchComponent( + ctx context.Context, component components.Component, destDirPath string, opts ...FetchComponentOption, +) error { if component.GetName() == "" { return errors.New("component name is empty") } @@ -392,7 +436,7 @@ func (m *sourceManager) FetchComponent(ctx context.Context, component components return m.fetchLocalComponent(ctx, component, destDirPath) case projectconfig.SpecSourceTypeUpstream: - return m.fetchUpstreamComponent(ctx, component, destDirPath) + return m.fetchUpstreamComponent(ctx, component, destDirPath, opts...) } return fmt.Errorf("spec for component %#q not found in any configured provider", @@ -444,7 +488,7 @@ func (m *sourceManager) downloadLookasideSources( } func (m *sourceManager) fetchUpstreamComponent( - ctx context.Context, component components.Component, destDirPath string, + ctx context.Context, component components.Component, destDirPath string, opts ...FetchComponentOption, ) error { if len(m.upstreamComponentProviders) == 0 { return fmt.Errorf("no upstream component origins configured for component %#q", @@ -455,7 +499,7 @@ func (m *sourceManager) fetchUpstreamComponent( // Try each upstream component provider, until one succeeds for _, provider := range m.upstreamComponentProviders { - err := provider.GetComponent(ctx, component, destDirPath) + err := provider.GetComponent(ctx, component, destDirPath, opts...) if err == nil { slog.Debug("Successfully fetched upstream component", "component", component.GetName(), diff --git a/internal/providers/sourceproviders/sourceproviders_test/sourcemanager_mocks.go b/internal/providers/sourceproviders/sourceproviders_test/sourcemanager_mocks.go index e6a6a23..db09ac8 100644 --- a/internal/providers/sourceproviders/sourceproviders_test/sourcemanager_mocks.go +++ b/internal/providers/sourceproviders/sourceproviders_test/sourcemanager_mocks.go @@ -14,6 +14,7 @@ import ( reflect "reflect" components "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" + sourceproviders "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders" gomock "go.uber.org/mock/gomock" ) @@ -42,17 +43,22 @@ func (m *MockSourceManager) EXPECT() *MockSourceManagerMockRecorder { } // FetchComponent mocks base method. -func (m *MockSourceManager) FetchComponent(ctx context.Context, component components.Component, destDirPath string) error { +func (m *MockSourceManager) FetchComponent(ctx context.Context, component components.Component, destDirPath string, opts ...sourceproviders.FetchComponentOption) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "FetchComponent", ctx, component, destDirPath) + varargs := []any{ctx, component, destDirPath} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "FetchComponent", varargs...) ret0, _ := ret[0].(error) return ret0 } // FetchComponent indicates an expected call of FetchComponent. -func (mr *MockSourceManagerMockRecorder) FetchComponent(ctx, component, destDirPath any) *gomock.Call { +func (mr *MockSourceManagerMockRecorder) FetchComponent(ctx, component, destDirPath any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchComponent", reflect.TypeOf((*MockSourceManager)(nil).FetchComponent), ctx, component, destDirPath) + varargs := append([]any{ctx, component, destDirPath}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchComponent", reflect.TypeOf((*MockSourceManager)(nil).FetchComponent), varargs...) } // FetchFiles mocks base method. diff --git a/scenario/internal/buildtest/buildtest.go b/scenario/internal/buildtest/buildtest.go index 9982666..b8f6f98 100644 --- a/scenario/internal/buildtest/buildtest.go +++ b/scenario/internal/buildtest/buildtest.go @@ -34,7 +34,9 @@ func NewBuildTest( componentName string, options ...projecttest.ProjectTestOption, ) *BuildTest { - projectTest := projecttest.NewProjectTest(project, []string{"component", "build", componentName}, options...) + projectTest := projecttest.NewProjectTest( + project, []string{"component", "build", componentName}, options..., + ) return &BuildTest{ inner: projectTest,