diff --git a/go.mod b/go.mod index c1ef7d5..70aef3e 100644 --- a/go.mod +++ b/go.mod @@ -6,29 +6,40 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/docker/docker v28.5.1+incompatible github.com/docker/go-connections v0.6.0 + github.com/go-git/go-git/v5 v5.16.3 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) require ( + dario.cat/mergo v1.0.0 // 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/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect github.com/charmbracelet/x/term v0.2.1 // 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 + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // 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/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -39,10 +50,14 @@ require ( github.com/muesli/termenv v0.16.0 // 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/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect @@ -50,7 +65,10 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 683c9f2..138d2ce 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,16 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 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/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/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -20,6 +29,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +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= @@ -27,6 +38,9 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -37,13 +51,29 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= 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/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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +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.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8= +github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= 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= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -52,8 +82,15 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= 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/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/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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -74,10 +111,14 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +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= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -85,18 +126,28 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -119,17 +170,32 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +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= @@ -139,8 +205,13 @@ google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 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/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/internal/cli/scan.go b/internal/cli/scan.go new file mode 100644 index 0000000..bd9de89 --- /dev/null +++ b/internal/cli/scan.go @@ -0,0 +1,276 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/ork-cli/ork/internal/config" + "github.com/ork-cli/ork/internal/git" + "github.com/ork-cli/ork/internal/ui" + "github.com/spf13/cobra" +) + +var scanCmd = &cobra.Command{ + Use: "scan", + Short: "Scan workspace directories for git repositories", + Long: ` +Scan configured workspace directories to discover git repositories. + +The scan results are cached for 24 hours to improve performance. Use --refresh to force a new scan. + +Workspace directories can be configured in ~/.ork/config.yml: + + workspaces: + - ~/code + - ~/projects + - ~/workspace + +If no configuration exists, ork will scan default directories: ~/code, ~/projects, ~/workspace`, + RunE: runScan, +} + +const ( + bulletFormat = " • %s" + tableRowFormat = "%s %s %s\n" +) + +var ( + scanRefresh bool +) + +func init() { + rootCmd.AddCommand(scanCmd) + scanCmd.Flags().BoolVar(&scanRefresh, "refresh", false, "Force a fresh scan, ignoring cache") +} + +func runScan(cmd *cobra.Command, args []string) error { + // Load global config + globalConfig, err := config.LoadGlobal() + if err != nil { + return fmt.Errorf("failed to load global config: %w", err) + } + + // Try to load from cache if not refreshing + if !scanRefresh { + if repos := tryLoadCache(globalConfig.Workspaces); repos != nil { + return nil // Cache was loaded and displayed + } + } + + // Invalidate cache if refreshing + if scanRefresh { + if err := git.InvalidateCache(); err != nil { + return fmt.Errorf("failed to invalidate cache: %w", err) + } + } + + // Filter and validate workspaces + existingWorkspaces := filterExistingWorkspaces(globalConfig.Workspaces) + if len(existingWorkspaces) == 0 { + return handleNoWorkspaces(globalConfig.Workspaces) + } + + // Display scanning message + displayScanningMessage(existingWorkspaces) + + // Perform discovery + repos, elapsed, err := performDiscovery(globalConfig.Workspaces) + if err != nil { + return err + } + + // Save to cache (non-fatal if it fails) + saveCacheIfPossible(repos) + + // Display results + displayResults(repos, elapsed, globalConfig.Workspaces) + + return nil +} + +func tryLoadCache(workspaces []string) []git.Repository { + cached, err := git.LoadCache() + if err == nil && cached != nil { + ui.Success("Loaded repositories from cache") + printRepositories(cached, workspaces) + fmt.Println() + fmt.Println(ui.Dim("Use 'ork scan --refresh' to force a fresh scan")) + return cached + } + return nil +} + +func filterExistingWorkspaces(workspaces []string) []string { + existing := []string{} + for _, workspace := range workspaces { + if workspaceExists(workspace) { + existing = append(existing, workspace) + } + } + return existing +} + +func workspaceExists(workspace string) bool { + // Expand ~ to the home directory + expandedPath := workspace + if strings.HasPrefix(workspace, "~/") { + home, err := os.UserHomeDir() + if err == nil { + expandedPath = filepath.Join(home, workspace[2:]) + } + } + + // Check if the directory exists + _, err := os.Stat(expandedPath) + return err == nil +} + +func handleNoWorkspaces(configuredWorkspaces []string) error { + ui.Warning("No workspace directories found") + fmt.Println() + fmt.Println("Configure workspaces in ~/.ork/config.yml or ensure these directories exist:") + for _, workspace := range configuredWorkspaces { + fmt.Println(ui.Dim(fmt.Sprintf(bulletFormat, workspace))) + } + return nil +} + +func displayScanningMessage(workspaces []string) { + ui.Info(fmt.Sprintf("Scanning %d workspace(s)...", len(workspaces))) + for _, workspace := range workspaces { + fmt.Println(ui.Dim(fmt.Sprintf(bulletFormat, workspace))) + } + fmt.Println() +} + +func performDiscovery(workspaces []string) ([]git.Repository, time.Duration, error) { + start := time.Now() + repos, err := git.DiscoverRepositories(workspaces, 3) + if err != nil { + return nil, 0, fmt.Errorf("failed to discover repositories: %w", err) + } + elapsed := time.Since(start) + return repos, elapsed, nil +} + +func saveCacheIfPossible(repos []git.Repository) { + if err := git.SaveCache(repos); err != nil { + ui.Warning(fmt.Sprintf("Warning: Failed to save cache: %v", err)) + } +} + +func displayResults(repos []git.Repository, elapsed time.Duration, workspaces []string) { + ui.Success(fmt.Sprintf("Found %d repositories in %v", len(repos), elapsed.Round(time.Millisecond))) + fmt.Println() + printRepositories(repos, workspaces) +} + +func printRepositories(repos []git.Repository, workspaces []string) { + if len(repos) == 0 { + ui.Warning("No git repositories found") + fmt.Println() + fmt.Println("Make sure you have repositories in your workspace directories:") + for _, workspace := range workspaces { + fmt.Println(ui.Dim(fmt.Sprintf(bulletFormat, workspace))) + } + return + } + + // Sort repositories by name + sort.Slice(repos, func(i, j int) bool { + return repos[i].Name < repos[j].Name + }) + + // Create header style + headerStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("12")) + + // Calculate column widths + nameWidth := len("NAME") + pathWidth := len("PATH") + urlWidth := len("GIT URL") + + for _, repo := range repos { + if len(repo.Name) > nameWidth { + nameWidth = len(repo.Name) + } + if len(repo.Path) > pathWidth { + pathWidth = len(repo.Path) + } + if len(repo.URL) > urlWidth { + urlWidth = len(repo.URL) + } + } + + // Limit max widths + if nameWidth > 30 { + nameWidth = 30 + } + if pathWidth > 60 { + pathWidth = 60 + } + if urlWidth > 60 { + urlWidth = 60 + } + + // Print header (pad first, then style) + fmt.Printf(tableRowFormat, + headerStyle.Render(padRight("NAME", nameWidth)), + headerStyle.Render(padRight("PATH", pathWidth)), + headerStyle.Render(padRight("GIT URL", urlWidth))) + + // Print separator + separatorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + fmt.Printf(tableRowFormat, + separatorStyle.Render(repeatChar("─", nameWidth)), + separatorStyle.Render(repeatChar("─", pathWidth)), + separatorStyle.Render(repeatChar("─", urlWidth))) + + // Print repositories + nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) + pathStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + urlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + + for _, repo := range repos { + name := truncate(repo.Name, nameWidth) + path := truncate(repo.Path, pathWidth) + url := truncate(repo.URL, urlWidth) + + // Pad first, then style - this keeps alignment correct + fmt.Printf(tableRowFormat, + nameStyle.Render(padRight(name, nameWidth)), + pathStyle.Render(padRight(path, pathWidth)), + urlStyle.Render(padRight(url, urlWidth))) + } +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +func padRight(s string, width int) string { + if len(s) >= width { + return s + } + return s + repeatChar(" ", width-len(s)) +} + +func repeatChar(char string, count int) string { + result := "" + for i := 0; i < count; i++ { + result += char + } + return result +} diff --git a/internal/config/config.go b/internal/config/config.go index decee0e..c165b51 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,3 +37,8 @@ type HealthCheck struct { Timeout string `yaml:"timeout"` // Request timeout (e.g., 3s) Retries int `yaml:"retries"` // Number of retries before unhealthy } + +// GlobalConfig represents the global ~/.ork/config.yml file structure +type GlobalConfig struct { + Workspaces []string `yaml:"workspaces"` // List of workspace directories to scan for git repos +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 891ecae..12e18a7 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -8,6 +8,19 @@ import ( "gopkg.in/yaml.v3" ) +// defaultWorkspaces returns the default workspace directories if none are configured +func defaultWorkspaces() []string { + home, err := os.UserHomeDir() + if err != nil { + return []string{} + } + return []string{ + filepath.Join(home, "code"), + filepath.Join(home, "projects"), + filepath.Join(home, "workspace"), + } +} + // ============================================================================ // Public API // ============================================================================ @@ -36,6 +49,43 @@ func Load() (*Config, error) { return &config, nil } +// LoadGlobal reads and parses the global ~/.ork/config.yml file +// Returns default configuration if the file doesn't exist +func LoadGlobal() (*GlobalConfig, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home directory: %w", err) + } + + configPath := filepath.Join(home, ".ork", "config.yml") + + // If file doesn't exist, return defaults + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return &GlobalConfig{ + Workspaces: defaultWorkspaces(), + }, nil + } + + // Read the file contents + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read global config file %s: %w", configPath, err) + } + + // Parse YAML + var config GlobalConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse YAML in %s: %w", configPath, err) + } + + // If no workspaces configured, use defaults + if len(config.Workspaces) == 0 { + config.Workspaces = defaultWorkspaces() + } + + return &config, nil +} + // ============================================================================ // Private Helpers // ============================================================================ diff --git a/internal/git/cache.go b/internal/git/cache.go new file mode 100644 index 0000000..3fc57e0 --- /dev/null +++ b/internal/git/cache.go @@ -0,0 +1,112 @@ +package git + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// cacheEntry represents a cached discovery result +type cacheEntry struct { + Timestamp time.Time `json:"timestamp"` + Repositories []Repository `json:"repositories"` +} + +const ( + cacheFileName = "discovery-cache.json" + cacheMaxAge = 24 * time.Hour // Cache is valid for 24 hours +) + +// getCachePath returns the path to the discovery cache file +func getCachePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + orkDir := filepath.Join(home, ".ork") + return filepath.Join(orkDir, cacheFileName), nil +} + +// LoadCache loads cached repositories if the cache is still valid +// Returns nil if the cache doesn't exist or is expired +func LoadCache() ([]Repository, error) { + cachePath, err := getCachePath() + if err != nil { + return nil, err + } + + // Check if the cache file exists + if _, err := os.Stat(cachePath); os.IsNotExist(err) { + return nil, nil // No cache + } + + // Read the cache file + data, err := os.ReadFile(cachePath) + if err != nil { + return nil, fmt.Errorf("failed to read cache file: %w", err) + } + + // Parse cache + var entry cacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return nil, fmt.Errorf("failed to parse cache file: %w", err) + } + + // Check if the cache is expired + if time.Since(entry.Timestamp) > cacheMaxAge { + return nil, nil // Expired cache + } + + return entry.Repositories, nil +} + +// SaveCache saves repositories to the cache file +func SaveCache(repos []Repository) error { + cachePath, err := getCachePath() + if err != nil { + return err + } + + // Ensure .ork directory exists + orkDir := filepath.Dir(cachePath) + if err := os.MkdirAll(orkDir, 0755); err != nil { + return fmt.Errorf("failed to create .ork directory: %w", err) + } + + // Create a cache entry + entry := cacheEntry{ + Timestamp: time.Now(), + Repositories: repos, + } + + // Marshal to JSON + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal cache: %w", err) + } + + // Write to the file + if err := os.WriteFile(cachePath, data, 0644); err != nil { + return fmt.Errorf("failed to write cache file: %w", err) + } + + return nil +} + +// InvalidateCache removes the cache file +func InvalidateCache() error { + cachePath, err := getCachePath() + if err != nil { + return err + } + + // Remove cache file (ignore error if it doesn't exist) + if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove cache file: %w", err) + } + + return nil +} diff --git a/internal/git/discovery.go b/internal/git/discovery.go new file mode 100644 index 0000000..4db6072 --- /dev/null +++ b/internal/git/discovery.go @@ -0,0 +1,226 @@ +package git + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/go-git/go-git/v5" +) + +// Repository represents a discovered git repository +type Repository struct { + Name string // Repository name (e.g., "frontend", "api") + Path string // Absolute path to the repository + URL string // Git remote URL (e.g., "github.com/org/repo") +} + +// DiscoverRepositories scans workspace directories and finds git repositories +// It searches up to maxDepth levels deep (default: 3) +func DiscoverRepositories(workspaceDirs []string, maxDepth int) ([]Repository, error) { + if maxDepth <= 0 { + maxDepth = 3 // Default depth + } + + var repos []Repository + seen := make(map[string]bool) // Track repos we've already found + + for _, workspace := range workspaceDirs { + expandedPath := expandHomePath(workspace) + if !directoryExists(expandedPath) { + continue + } + + found, err := scanDirectory(expandedPath, 0, maxDepth) + if err != nil { + return nil, fmt.Errorf("failed to scan workspace %s: %w", workspace, err) + } + + repos = deduplicateRepos(repos, found, seen) + } + + return repos, nil +} + +// expandHomePath expands ~ to the home directory +func expandHomePath(path string) string { + if !strings.HasPrefix(path, "~/") { + return path + } + + home, err := os.UserHomeDir() + if err != nil { + return path + } + + return filepath.Join(home, path[2:]) +} + +// directoryExists checks if a directory exists +func directoryExists(path string) bool { + _, err := os.Stat(path) + return !os.IsNotExist(err) +} + +// deduplicateRepos adds new repos to the list, skipping duplicates +func deduplicateRepos(existing, found []Repository, seen map[string]bool) []Repository { + for _, repo := range found { + if !seen[repo.Path] { + existing = append(existing, repo) + seen[repo.Path] = true + } + } + return existing +} + +// scanDirectory recursively searches for git repositories up to maxDepth +func scanDirectory(dir string, currentDepth, maxDepth int) ([]Repository, error) { + if currentDepth > maxDepth { + return []Repository{}, nil + } + + if isGitRepository(dir) { + return handleGitRepository(dir) + } + + return scanSubdirectories(dir, currentDepth, maxDepth) +} + +// handleGitRepository creates a repository entry for a git directory +func handleGitRepository(dir string) ([]Repository, error) { + repo, err := createRepository(dir) + if err != nil { + return []Repository{}, nil + } + return []Repository{repo}, nil +} + +// scanSubdirectories recursively scans subdirectories for git repositories +func scanSubdirectories(dir string, currentDepth, maxDepth int) ([]Repository, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return []Repository{}, nil // Permission denied or other errors + } + + var repos []Repository + for _, entry := range entries { + if shouldSkipDirectory(entry) { + continue + } + + subdirPath := filepath.Join(dir, entry.Name()) + found, err := scanDirectory(subdirPath, currentDepth+1, maxDepth) + if err != nil { + continue + } + + repos = append(repos, found...) + } + + return repos, nil +} + +// shouldSkipDirectory determines if a directory should be skipped during scanning +func shouldSkipDirectory(entry os.DirEntry) bool { + if !entry.IsDir() { + return true + } + + name := entry.Name() + + // Skip hidden directories (except .ork) + if strings.HasPrefix(name, ".") && name != ".ork" { + return true + } + + // Skip common non-code directories + skipDirs := []string{"node_modules", "vendor", "dist", "build"} + for _, skipDir := range skipDirs { + if name == skipDir { + return true + } + } + + return false +} + +// isGitRepository checks if a directory is a git repository +func isGitRepository(dir string) bool { + gitDir := filepath.Join(dir, ".git") + info, err := os.Stat(gitDir) + if err != nil { + return false + } + return info.IsDir() +} + +// createRepository creates a Repository struct from a git repository path +func createRepository(path string) (Repository, error) { + // Open the repository + repo, err := git.PlainOpen(path) + if err != nil { + return Repository{}, fmt.Errorf("failed to open git repository: %w", err) + } + + // Get the repository name (last component of the path) + name := filepath.Base(path) + + // Try to get the remote URL + url := "" + remotes, err := repo.Remotes() + if err == nil && len(remotes) > 0 { + // Get the first remote (usually "origin") + remote := remotes[0] + if len(remote.Config().URLs) > 0 { + url = normalizeGitURL(remote.Config().URLs[0]) + } + } + + return Repository{ + Name: name, + Path: path, + URL: url, + }, nil +} + +// normalizeGitURL converts a git URL to a normalized form (e.g., github.com/org/repo) +// Handles both SSH and HTTPS URLs +func normalizeGitURL(url string) string { + // Remove .git suffix + url = strings.TrimSuffix(url, ".git") + + // Handle SSH URLs (git@github.com:org/repo) + if strings.HasPrefix(url, "git@") { + url = strings.TrimPrefix(url, "git@") + url = strings.Replace(url, ":", "/", 1) + return url + } + + // Handle HTTPS URLs (https://github.com/org/repo) + if strings.HasPrefix(url, "https://") { + url = strings.TrimPrefix(url, "https://") + return url + } + + // Handle HTTP URLs (http://github.com/org/repo) + if strings.HasPrefix(url, "http://") { + url = strings.TrimPrefix(url, "http://") + return url + } + + return url +} + +// FindRepository searches for a repository by git URL in the discovered repos +func FindRepository(repos []Repository, gitURL string) *Repository { + normalized := normalizeGitURL(gitURL) + + for i := range repos { + if repos[i].URL == normalized { + return &repos[i] + } + } + + return nil +} diff --git a/internal/git/discovery_test.go b/internal/git/discovery_test.go new file mode 100644 index 0000000..5cae2d2 --- /dev/null +++ b/internal/git/discovery_test.go @@ -0,0 +1,209 @@ +package git + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeGitURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "SSH URL", + input: "git@github.com:org/repo.git", + expected: "github.com/org/repo", + }, + { + name: "HTTPS URL", + input: "https://github.com/org/repo.git", + expected: "github.com/org/repo", + }, + { + name: "HTTP URL", + input: "http://github.com/org/repo.git", + expected: "github.com/org/repo", + }, + { + name: "Already normalized", + input: "github.com/org/repo", + expected: "github.com/org/repo", + }, + { + name: "SSH URL without .git", + input: "git@gitlab.com:company/project", + expected: "gitlab.com/company/project", + }, + { + name: "HTTPS URL without .git", + input: "https://bitbucket.org/team/repo", + expected: "bitbucket.org/team/repo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeGitURL(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsGitRepository(t *testing.T) { + // Create temporary directory + tmpDir := t.TempDir() + + // Create a fake .git directory + gitDir := filepath.Join(tmpDir, ".git") + err := os.Mkdir(gitDir, 0755) + require.NoError(t, err) + + // Test that it's detected as a git repo + assert.True(t, isGitRepository(tmpDir)) + + // Test that a non-git directory is not detected + nonGitDir := filepath.Join(tmpDir, "not-a-repo") + err = os.Mkdir(nonGitDir, 0755) + require.NoError(t, err) + assert.False(t, isGitRepository(nonGitDir)) + + // Test that a non-existent directory is not detected + assert.False(t, isGitRepository(filepath.Join(tmpDir, "does-not-exist"))) +} + +func TestDiscoverRepositories(t *testing.T) { + // Create temporary workspace + workspace := t.TempDir() + + // Create a real git repository + repo1Path := filepath.Join(workspace, "repo1") + err := os.Mkdir(repo1Path, 0755) + require.NoError(t, err) + + _, err = git.PlainInit(repo1Path, false) + require.NoError(t, err) + + // Create a nested git repository (should be found within depth 3) + nestedPath := filepath.Join(workspace, "projects", "nested", "repo2") + err = os.MkdirAll(nestedPath, 0755) + require.NoError(t, err) + + _, err = git.PlainInit(nestedPath, false) + require.NoError(t, err) + + // Create a directory that's too deep (should not be found with depth 2) + deepPath := filepath.Join(workspace, "a", "b", "c", "d", "repo3") + err = os.MkdirAll(deepPath, 0755) + require.NoError(t, err) + + _, err = git.PlainInit(deepPath, false) + require.NoError(t, err) + + // Test discovery with depth 3 (should find repo1 and repo2, not repo3) + repos, err := DiscoverRepositories([]string{workspace}, 3) + require.NoError(t, err) + assert.Equal(t, 2, len(repos)) + + // Verify repo names + repoNames := make(map[string]bool) + for _, repo := range repos { + repoNames[repo.Name] = true + } + assert.True(t, repoNames["repo1"]) + assert.True(t, repoNames["repo2"]) + assert.False(t, repoNames["repo3"]) +} + +func TestDiscoverRepositories_SkipsNodeModules(t *testing.T) { + // Create temporary workspace + workspace := t.TempDir() + + // Create a repo in workspace root + repo1Path := filepath.Join(workspace, "myproject") + err := os.Mkdir(repo1Path, 0755) + require.NoError(t, err) + + _, err = git.PlainInit(repo1Path, false) + require.NoError(t, err) + + // Create a fake repo inside node_modules (should be skipped) + nodeModulesPath := filepath.Join(workspace, "myproject", "node_modules", "some-package") + err = os.MkdirAll(nodeModulesPath, 0755) + require.NoError(t, err) + + _, err = git.PlainInit(nodeModulesPath, false) + require.NoError(t, err) + + // Test discovery + repos, err := DiscoverRepositories([]string{workspace}, 3) + require.NoError(t, err) + + // Should only find myproject, not the repo in node_modules + assert.Equal(t, 1, len(repos)) + assert.Equal(t, "myproject", repos[0].Name) +} + +func TestDiscoverRepositories_NonExistentWorkspace(t *testing.T) { + // Test with a non-existent workspace + repos, err := DiscoverRepositories([]string{"/this/path/does/not/exist"}, 3) + require.NoError(t, err) + assert.Equal(t, 0, len(repos), "Should return empty list for non-existent workspace") +} + +func TestFindRepository(t *testing.T) { + repos := []Repository{ + {Name: "frontend", Path: "/home/user/code/frontend", URL: "github.com/org/frontend"}, + {Name: "backend", Path: "/home/user/code/backend", URL: "github.com/org/backend"}, + {Name: "api", Path: "/home/user/code/api", URL: "gitlab.com/team/api"}, + } + + tests := []struct { + name string + searchURL string + found bool + repoName string + }{ + { + name: "Find exact match", + searchURL: "github.com/org/frontend", + found: true, + repoName: "frontend", + }, + { + name: "Find with SSH URL", + searchURL: "git@github.com:org/backend.git", + found: true, + repoName: "backend", + }, + { + name: "Find with HTTPS URL", + searchURL: "https://gitlab.com/team/api.git", + found: true, + repoName: "api", + }, + { + name: "Not found", + searchURL: "github.com/org/notfound", + found: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FindRepository(repos, tt.searchURL) + if tt.found { + require.NotNil(t, result) + assert.Equal(t, tt.repoName, result.Name) + } else { + assert.Nil(t, result) + } + }) + } +}