diff --git a/README.md b/README.md index b370b94..39e10aa 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Install using Go, go install github.com/nnutter/git-wt@latest ``` +`git-wt` requires Git on `PATH`. + ## Shell integration Generate a zsh function that wraps the CLI (default name `wt`): @@ -38,7 +40,7 @@ Ensure the output directory is on `fpath`, then restart zsh or run `compinit`. The generated function: - routes most commands to `git-wt` (`wt create`, `wt list`, `wt prune`, …) -- after a successful `wt create`, `cd`s into the new worktree (`--no-cd` or `-r` | `--herdr` to stay put) +- after a successful `wt create`, `cd`s into the new worktree unless `--no-cd`, `-r` | `--herdr`, or automatic Herdr workspace creation applies - provides a shell-only `switch` that `cd`s into a worktree - after a successful `wt remove`, `cd`s to the main worktree @@ -67,7 +69,10 @@ Create a managed worktree for a branch. - If the branch already exists, the worktree is created from that branch. - If the branch does not exist, it is created from the branch pointed at by `origin/HEAD`; set it explicitly with `--upstream` | `-u`. -- With `-r` | `--herdr`, also create a [Herdr](https://herdr.dev) workspace whose `--cwd` is the new worktree and whose `--label` is the worktree name (branch name). `wt create -r` implies `--no-cd`. Requires `herdr` on `PATH` and a running Herdr server. +- When run inside [Herdr](https://herdr.dev) (`HERDR_ENV=1`), automatically create a Herdr workspace whose `--cwd` is the new worktree and whose `--label` is the worktree name (branch name). +- Use `-r` | `--herdr` to create a Herdr workspace explicitly, or `-R` | `--no-herdr` to suppress automatic creation. +- Herdr workspace creation through `wt create` implies `--no-cd`. +- Herdr workspace creation requires `herdr` on `PATH` and a running Herdr server. Example: @@ -75,6 +80,7 @@ Example: git-wt create feature/login git-wt create -u origin/v1.2 hotfix/1.2.1 git-wt create -r feature/login +git-wt create -R feature/login ``` ### `git-wt list` diff --git a/go.mod b/go.mod index c3e9449..3786834 100644 --- a/go.mod +++ b/go.mod @@ -6,16 +6,12 @@ require ( charm.land/fang/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.1 github.com/charmbracelet/huh v1.0.0 - github.com/go-git/go-git/v5 v5.16.5 github.com/google/uuid v1.6.0 github.com/samber/lo v1.53.0 github.com/spf13/cobra v1.10.1 ) require ( - dario.cat/mergo v1.0.2 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect @@ -33,18 +29,9 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/cloudflare/circl v1.6.4 // indirect - github.com/cyphar/filepath-securejoin v0.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emirpasic/gods v1.18.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.9.0 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.6.0 // indirect - github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -57,17 +44,12 @@ require ( github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sergi/go-diff v1.4.0 // indirect - github.com/skeema/knownhosts v1.3.2 // indirect github.com/spf13/pflag v1.0.9 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 6cd81c4..f85ee7e 100644 --- a/go.sum +++ b/go.sum @@ -2,19 +2,8 @@ charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= -github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= -github.com/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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -61,55 +50,19 @@ 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.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= -github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= -github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= -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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/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/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.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= -github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= -github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= -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= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= -github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= -github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= -github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= -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.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -134,73 +87,31 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 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/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= -github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= -github.com/pkg/errors v0.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= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.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/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= -github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= 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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/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= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -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-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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= diff --git a/internal/gitwt/gitwt_create.go b/internal/gitwt/gitwt_create.go index ee6cd5a..e8626c4 100644 --- a/internal/gitwt/gitwt_create.go +++ b/internal/gitwt/gitwt_create.go @@ -10,6 +10,7 @@ import ( type createCommandOptions struct { upstream string herdr bool + noHerdr bool } func NewCreateCommand() *cobra.Command { @@ -24,13 +25,15 @@ func NewCreateCommand() *cobra.Command { command.Flags().StringVarP(&options.upstream, "upstream", "u", "", "Upstream branch") command.Flags().BoolVarP(&options.herdr, "herdr", "r", false, "Also create a Herdr workspace for the new worktree") + command.Flags().BoolVarP(&options.noHerdr, "no-herdr", "R", false, "Do not create a Herdr workspace") + command.MarkFlagsMutuallyExclusive("herdr", "no-herdr") return command } func (x *createCommandOptions) Execute(command *cobra.Command, args []string) error { branchName := args[0] - repository, err := PlainOpenWithOptions(".") + repository, err := openRepository(".") if err != nil { return err } @@ -81,7 +84,7 @@ func (x *createCommandOptions) Execute(command *cobra.Command, args []string) er return err } - if !x.herdr { + if !x.shouldCreateHerdrWorkspace() { return nil } @@ -92,3 +95,7 @@ func (x *createCommandOptions) Execute(command *cobra.Command, args []string) er _, err = fmt.Fprintf(command.ErrOrStderr(), "%s\n", statusStyle.Render("created herdr workspace "+branchName)) return err } + +func (x *createCommandOptions) shouldCreateHerdrWorkspace() bool { + return x.herdr || (!x.noHerdr && runningInHerdr()) +} diff --git a/internal/gitwt/gitwt_generate_zsh.go b/internal/gitwt/gitwt_generate_zsh.go index 7892ec9..995cb27 100644 --- a/internal/gitwt/gitwt_generate_zsh.go +++ b/internal/gitwt/gitwt_generate_zsh.go @@ -80,7 +80,7 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error { case "$1" in create) shift - local no_cd=0 name="" skip_next=0 + local no_cd=0 herdr=0 no_herdr=0 name="" skip_next=0 local -a forward=() local arg for arg in "$@"; do @@ -94,7 +94,11 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error { no_cd=1 ;; -r|--herdr) - no_cd=1 + herdr=1 + forward+=("$arg") + ;; + -R|--no-herdr) + no_herdr=1 forward+=("$arg") ;; -u|--upstream) @@ -114,7 +118,7 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error { esac done command git-wt create "${forward[@]}" || return $? - if (( no_cd )); then + if (( no_cd || herdr || ( ${HERDR_ENV:-0} == 1 && ! no_herdr ) )); then return 0 fi if [[ -z "$name" ]]; then @@ -226,6 +230,7 @@ _` + x.name + `() { _arguments \ '--no-cd[Create without changing directories]' \ '(-r --herdr)'{-r,--herdr}'[Also create a Herdr workspace for the new worktree]' \ + '(-R --no-herdr)'{-R,--no-herdr}'[Do not create a Herdr workspace]' \ '(-u --upstream)'{-u,--upstream}'[Upstream branch]:upstream branch:' \ '(-h --help)'{-h,--help}'[help for create]' \ '1:worktree name:' diff --git a/internal/gitwt/gitwt_list.go b/internal/gitwt/gitwt_list.go index 9bafc15..3e36116 100644 --- a/internal/gitwt/gitwt_list.go +++ b/internal/gitwt/gitwt_list.go @@ -24,7 +24,7 @@ func NewListCommand() *cobra.Command { } func (x *listCommandOptions) Execute(command *cobra.Command, args []string) error { - repository, err := PlainOpenWithOptions(".") + repository, err := openRepository(".") if err != nil { return err } diff --git a/internal/gitwt/gitwt_migrate.go b/internal/gitwt/gitwt_migrate.go index 693c926..ff7e515 100644 --- a/internal/gitwt/gitwt_migrate.go +++ b/internal/gitwt/gitwt_migrate.go @@ -49,7 +49,7 @@ func NewMigrateCommand() *cobra.Command { } func (x *migrateCommandOptions) Execute(command *cobra.Command, args []string) error { - repository, err := PlainOpenWithOptions(".") + repository, err := openRepository(".") if err != nil { return err } diff --git a/internal/gitwt/gitwt_prune.go b/internal/gitwt/gitwt_prune.go index 1eac92d..d8ea7d1 100644 --- a/internal/gitwt/gitwt_prune.go +++ b/internal/gitwt/gitwt_prune.go @@ -38,7 +38,7 @@ func NewPruneCommand() *cobra.Command { } func (x *pruneCommandOptions) Execute(command *cobra.Command, args []string) error { - repository, err := PlainOpenWithOptions(".") + repository, err := openRepository(".") if err != nil { return err } diff --git a/internal/gitwt/gitwt_remove.go b/internal/gitwt/gitwt_remove.go index 308513a..fd6c596 100644 --- a/internal/gitwt/gitwt_remove.go +++ b/internal/gitwt/gitwt_remove.go @@ -35,7 +35,7 @@ func completeManagedWorktreeNames(command *cobra.Command, args []string, toCompl return nil, cobra.ShellCompDirectiveNoFileComp } - repository, err := PlainOpenWithOptions(".") + repository, err := openRepository(".") if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -61,7 +61,7 @@ func (x *removeCommandOptions) Execute(command *cobra.Command, args []string) er } func (x *removeCommandOptions) removeWorktree(command *cobra.Command, name string, force bool) error { - repository, err := PlainOpenWithOptions(".") + repository, err := openRepository(".") if err != nil { return err } @@ -73,7 +73,7 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, name strin } if filepath.Clean(currentWorkTree) != filepath.Clean(mainPath) { - repository, err = PlainOpenWithOptions(mainPath) + repository, err = openRepository(mainPath) if err != nil { return err } @@ -109,7 +109,7 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, name strin return fmt.Errorf("worktree %q is not clean", name) } if !force && !worktree.Merged { - return fmt.Errorf("branch %q is not merged to %s", name, worktree.UpstreamRef.Short()) + return fmt.Errorf("branch %q is not merged to %s", name, shortReference(worktree.UpstreamRef)) } removeArguments := []string{"worktree", "remove"} diff --git a/internal/gitwt/gitwt_test.go b/internal/gitwt/gitwt_test.go index f811a85..b7ae881 100644 --- a/internal/gitwt/gitwt_test.go +++ b/internal/gitwt/gitwt_test.go @@ -2,6 +2,7 @@ package gitwt import ( "bytes" + "errors" "fmt" "io" "os" @@ -86,6 +87,19 @@ func TestCreateSucceedsWithWorktreeConfig(t *testing.T) { } } +func TestListSucceedsWithWorktreeConfig(t *testing.T) { + const branchName = "feature/worktree-config" + + testRepository := newTestRepository(t) + runGitCommand(t, testRepository.mainPath, "config", "extensions.worktreeConfig", "true") + testRepository.runGitWT(t, "create", branchName) + + result := testRepository.runGitWT(t, "list") + if result.err != nil { + t.Fatalf("list failed: %v\n%s", result.err, result.stderr) + } +} + func TestCreateUsesOriginHeadAsDefaultUpstream(t *testing.T) { const defaultBranch = "default" const branchName = "feature/origin-head" @@ -118,6 +132,24 @@ func TestCreateUsesOriginHeadAsDefaultUpstream(t *testing.T) { } } +func TestCreateFailsWhenOriginHeadIsMissing(t *testing.T) { + testRepository := newTestRepository(t) + runGitCommand(t, testRepository.mainPath, "remote", "set-head", "--delete", remoteName) + + result := testRepository.runGitWT(t, "create", "feature/missing-origin-head") + if result.err == nil { + t.Fatal("create succeeded without origin/HEAD") + } + if !strings.Contains(result.err.Error(), "resolve origin/HEAD") { + t.Fatalf("create error = %q, want origin/HEAD resolution error", result.err) + } + + var exitError *exec.ExitError + if !errors.As(result.err, &exitError) { + t.Fatalf("create error = %q, want Git command error", result.err) + } +} + func TestCreateWithHerdrInvokesHerdrWorkspaceCreate(t *testing.T) { const branchName = "feature/herdr" @@ -165,6 +197,61 @@ func TestCreateWithoutHerdrDoesNotInvokeHerdr(t *testing.T) { } } +func TestCreateInHerdrInvokesHerdrWorkspaceCreate(t *testing.T) { + const branchName = "feature/automatic-herdr" + + testRepository := newTestRepository(t) + t.Setenv("HERDR_ENV", "1") + logPath := filepath.Join(t.TempDir(), "herdr.log") + installFakeHerdr(t, logPath, 0) + + result := testRepository.runGitWT(t, "create", branchName) + if result.err != nil { + t.Fatalf("create in Herdr failed: %v\n%s", result.err, result.stderr) + } + if _, err := os.Stat(logPath); err != nil { + t.Fatalf("expected Herdr to run: %v", err) + } +} + +func TestCreateWithNoHerdrDoesNotInvokeHerdr(t *testing.T) { + testCases := []struct { + name string + flag string + }{ + {name: "short", flag: "-R"}, + {name: "long", flag: "--no-herdr"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + branchName := "feature/no-herdr-" + testCase.name + testRepository := newTestRepository(t) + t.Setenv("HERDR_ENV", "1") + logPath := filepath.Join(t.TempDir(), "herdr.log") + installFakeHerdr(t, logPath, 0) + + result := testRepository.runGitWT(t, "create", testCase.flag, branchName) + if result.err != nil { + t.Fatalf("create %s failed: %v\n%s", testCase.flag, result.err, result.stderr) + } + if _, err := os.Stat(logPath); !os.IsNotExist(err) { + t.Fatalf("expected Herdr not to run, log err=%v", err) + } + }) + } +} + +func TestCreateRejectsHerdrAndNoHerdr(t *testing.T) { + result := runGitWTCommand(t, "create", "-r", "-R", "feature/conflicting-herdr") + if result.err == nil { + t.Fatal("expected create with conflicting Herdr flags to fail") + } + if !strings.Contains(result.err.Error(), "if any flags in the group [herdr no-herdr] are set none of the others can be") { + t.Fatalf("expected mutually exclusive flag error, got: %v", result.err) + } +} + func TestCreateWithHerdrKeepsWorktreeWhenHerdrFails(t *testing.T) { const branchName = "feature/herdr-fail" @@ -495,7 +582,9 @@ func TestGenerateZshGeneratesWrapperFunctionAndCompletion(t *testing.T) { "create)", "--no-cd)", "-r|--herdr)", - "no_cd=1", + "-R|--no-herdr)", + "local no_cd=0 herdr=0 no_herdr=0", + "${HERDR_ENV:-0} == 1 && ! no_herdr", "command git-wt create \"${forward[@]}\"", "switch)", "remove)", @@ -517,9 +606,12 @@ func TestGenerateZshGeneratesWrapperFunctionAndCompletion(t *testing.T) { if strings.Contains(functionText, "${name//\\//.}") || strings.Contains(functionText, "${arg//\\//.}") { t.Fatalf("function still normalizes slashes in paths:\n%s", functionText) } - if !strings.Contains(functionText, "-r|--herdr)\n no_cd=1\n forward+=(\"$arg\")") { + if !strings.Contains(functionText, "-r|--herdr)\n herdr=1\n forward+=(\"$arg\")") { t.Fatalf("function does not make --herdr imply --no-cd:\n%s", functionText) } + if !strings.Contains(functionText, "-R|--no-herdr)\n no_herdr=1\n forward+=(\"$arg\")") { + t.Fatalf("function does not suppress automatic Herdr behavior:\n%s", functionText) + } completionText := string(completionContent) for _, want := range []string{ @@ -531,6 +623,7 @@ func TestGenerateZshGeneratesWrapperFunctionAndCompletion(t *testing.T) { "create)", "--no-cd[Create without changing directories]", "'(-r --herdr)'{-r,--herdr}'[Also create a Herdr workspace for the new worktree]'", + "'(-R --no-herdr)'{-R,--no-herdr}'[Do not create a Herdr workspace]'", "'(-u --upstream)'{-u,--upstream}'[Upstream branch]:upstream branch:'", "switch|remove)", "worktrees=(main)", @@ -628,6 +721,124 @@ func TestPruneKeepsWorktreeWhenUpstreamRefIsMissing(t *testing.T) { testRepository.assertPathPresent(t, testRepository.worktreePath(branchName)) } +func TestRemovePreservesReferenceLikeBranchNames(t *testing.T) { + const ordinaryBranchName = "topic" + const referenceLikeBranchName = "refs/remotes/topic" + + testRepository := newTestRepository(t) + testRepository.createLocalBranch(t, ordinaryBranchName) + testRepository.createLocalBranch(t, referenceLikeBranchName) + + createResult := testRepository.runGitWT(t, "create", referenceLikeBranchName) + if createResult.err != nil { + t.Fatalf("create failed: %v\n%s", createResult.err, createResult.stderr) + } + + listResult := testRepository.runGitWT(t, "list") + if !strings.Contains(listResult.stdout, referenceLikeBranchName) { + t.Fatalf("list output missing branch %q: %s", referenceLikeBranchName, listResult.stdout) + } + + removeResult := testRepository.runGitWT(t, "remove", referenceLikeBranchName) + if removeResult.err != nil { + t.Fatalf("remove failed: %v\n%s", removeResult.err, removeResult.stderr) + } + + testRepository.assertBranchMissing(t, referenceLikeBranchName) + testRepository.assertBranchPresent(t, ordinaryBranchName) +} + +func TestListSupportsLocalUpstream(t *testing.T) { + const branchName = "feature/local-upstream" + + testRepository := newTestRepository(t) + testRepository.runGitWT(t, "create", branchName) + runGitCommand(t, testRepository.mainPath, "config", "branch."+branchName+".remote", ".") + runGitCommand(t, testRepository.mainPath, "config", "branch."+branchName+".merge", "refs/heads/main") + + result := testRepository.runGitWT(t, "list") + if result.err != nil { + t.Fatalf("list failed: %v\n%s", result.err, result.stderr) + } +} + +func TestListSupportsCustomRemoteUpstream(t *testing.T) { + const branchName = "feature/custom-remote" + const customRemote = "upstream" + + testRepository := newTestRepository(t) + testRepository.runGitWT(t, "create", branchName) + runGitCommand(t, testRepository.mainPath, "remote", "add", customRemote, testRepository.remotePath) + runGitCommand(t, testRepository.mainPath, "fetch", customRemote) + runGitCommand(t, testRepository.mainPath, "config", "branch."+branchName+".remote", customRemote) + runGitCommand(t, testRepository.mainPath, "config", "branch."+branchName+".merge", "refs/heads/main") + + result := testRepository.runGitWT(t, "list") + if result.err != nil { + t.Fatalf("list failed: %v\n%s", result.err, result.stderr) + } +} + +func TestListFailsWhenTrackingConfigurationDoesNotMapToFetchRefspec(t *testing.T) { + const branchName = "feature/unmapped-upstream" + + testCases := []struct { + name string + remote string + setup func(testRepository) + }{ + { + name: "missing remote", + remote: "missing", + }, + { + name: "unmapped fetch refspec", + remote: "upstream", + setup: func(testRepository testRepository) { + runGitCommand(t, testRepository.mainPath, "remote", "add", "upstream", testRepository.remotePath) + runGitCommand(t, testRepository.mainPath, "config", "remote.upstream.fetch", "+refs/changes/*:refs/remotes/upstream/changes/*") + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + testRepository := newTestRepository(t) + if testCase.setup != nil { + testCase.setup(testRepository) + } + + createResult := testRepository.runGitWT(t, "create", branchName) + if createResult.err != nil { + t.Fatalf("create failed: %v\n%s", createResult.err, createResult.stderr) + } + runGitCommand(t, testRepository.mainPath, "config", "branch."+branchName+".remote", testCase.remote) + runGitCommand(t, testRepository.mainPath, "config", "branch."+branchName+".merge", "refs/heads/main") + + listResult := testRepository.runGitWT(t, "list") + if listResult.err == nil { + t.Fatal("list succeeded with an unmapped upstream") + } + if !strings.Contains(listResult.err.Error(), "does not map to a known fetch refspec") { + t.Fatalf("list error = %q, want unmapped upstream error", listResult.err) + } + }) + } +} + +func TestListFailsWhenBranchHasNoUpstream(t *testing.T) { + const branchName = "feature/no-upstream" + + testRepository := newTestRepository(t) + testRepository.createLocalBranch(t, branchName) + testRepository.runGitWT(t, "migrate") + + result := testRepository.runGitWT(t, "list") + if result.err == nil { + t.Fatal("list succeeded for a branch without an upstream") + } +} + func TestPrunePromptCanForceRemoveSelectedWorktrees(t *testing.T) { const branchName = "feature/prompt" const workFileName = "work.txt" @@ -758,6 +969,7 @@ type testRepository struct { func newTestRepository(t *testing.T) testRepository { t.Helper() + t.Setenv("HERDR_ENV", "") rootPath := t.TempDir() remotePath := filepath.Join(rootPath, "remote.git") diff --git a/internal/gitwt/herdr.go b/internal/gitwt/herdr.go index 2019997..6b3f205 100644 --- a/internal/gitwt/herdr.go +++ b/internal/gitwt/herdr.go @@ -2,11 +2,16 @@ package gitwt import ( "fmt" + "os" "os/exec" "path/filepath" "strings" ) +func runningInHerdr() bool { + return os.Getenv("HERDR_ENV") == "1" +} + func createHerdrWorkspace(worktreePath string, label string) error { absolutePath, err := filepath.Abs(worktreePath) if err != nil { diff --git a/internal/gitwt/repository.go b/internal/gitwt/repository.go index dc0d9e5..b38f34e 100644 --- a/internal/gitwt/repository.go +++ b/internal/gitwt/repository.go @@ -6,49 +6,40 @@ import ( "os/exec" "slices" "strings" - - git "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" ) -const remoteName = "origin" +const ( + remoteName = "origin" + branchRefPrefix = "refs/heads/" + remoteRefPrefix = "refs/remotes/" +) -func PlainOpenWithOptions(path string) (*Repository, error) { - gitRepository, err := git.PlainOpenWithOptions(path, &git.PlainOpenOptions{DetectDotGit: true}) - if err != nil { - return nil, fmt.Errorf("open repository: %w", err) - } +type referenceName string +func openRepository(path string) (*Repository, error) { workTreeResult, err := gitOutput(path, "rev-parse", "--show-toplevel") if err != nil { - return nil, err + return nil, fmt.Errorf("open repository: %w", err) } gitDirResult, err := gitOutput(path, "rev-parse", "--absolute-git-dir") if err != nil { - return nil, err + return nil, fmt.Errorf("resolve Git directory: %w", err) } - return &Repository{ - Repository: gitRepository, - GitDir: gitDirResult.stdout, - WorkTree: workTreeResult.stdout, - }, nil + return &Repository{GitDir: gitDirResult.stdout, WorkTree: workTreeResult.stdout}, nil } type Repository struct { - *git.Repository - GitDir string WorkTree string } func (x *Repository) branchExists(branchName string) (bool, error) { - branchRef := plumbing.NewBranchReferenceName(branchName) - return x.branchStillExists(branchRef) + return x.branchStillExists(branchReference(branchName)) } -func (x *Repository) branchMergedToUpstream(branchRef plumbing.ReferenceName, upstreamRef plumbing.ReferenceName) (bool, error) { +func (x *Repository) branchMergedToUpstream(branchRef referenceName, upstreamRef referenceName) (bool, error) { upstreamExists, err := x.branchStillExists(upstreamRef) if err != nil { return false, err @@ -57,7 +48,7 @@ func (x *Repository) branchMergedToUpstream(branchRef plumbing.ReferenceName, up return false, nil } - _, err = x.git("merge-base", "--is-ancestor", branchRef.String(), upstreamRef.String()) + _, err = x.git("merge-base", "--is-ancestor", string(branchRef), string(upstreamRef)) if err == nil { return true, nil } @@ -70,8 +61,8 @@ func (x *Repository) branchMergedToUpstream(branchRef plumbing.ReferenceName, up return false, err } -func (x *Repository) branchStillExists(branchRef plumbing.ReferenceName) (bool, error) { - _, err := x.git("show-ref", "--verify", "--quiet", branchRef.String()) +func (x *Repository) branchStillExists(branchRef referenceName) (bool, error) { + _, err := x.git("show-ref", "--verify", "--quiet", string(branchRef)) if err == nil { return true, nil } @@ -121,7 +112,7 @@ func (x porcelainWorktree) branchName() string { return "" } - return plumbing.ReferenceName(x.BranchRef).Short() + return shortReference(referenceName(x.BranchRef)) } func (x *Repository) listPorcelainWorktrees() ([]porcelainWorktree, error) { @@ -162,18 +153,16 @@ func (x *Repository) listPorcelainWorktrees() ([]porcelainWorktree, error) { } func (x *Repository) localBranches() ([]string, error) { - branchIter, err := x.Branches() + result, err := x.git("for-each-ref", "--format=%(refname)", branchRefPrefix) if err != nil { return nil, fmt.Errorf("list local branches: %w", err) } branches := make([]string, 0) - err = branchIter.ForEach(func(branchRef *plumbing.Reference) error { - branches = append(branches, branchRef.Name().Short()) - return nil - }) - if err != nil { - return nil, fmt.Errorf("iterate local branches: %w", err) + for branchRef := range strings.SplitSeq(result.stdout, "\n") { + if branchName := shortReference(referenceName(branchRef)); branchName != "" { + branches = append(branches, branchName) + } } slices.Sort(branches) @@ -212,33 +201,97 @@ func (x *Repository) mainWorktreeBranch() (string, error) { } func (x *Repository) remoteHeadBranch() (string, error) { - remoteHeadRef, err := x.Reference(plumbing.NewRemoteHEADReferenceName(remoteName), false) - if err == nil && remoteHeadRef.Type() == plumbing.SymbolicReference { - return remoteHeadRef.Target().Short(), nil - } - - result, commandErr := x.git("symbolic-ref", "refs/remotes/origin/HEAD") - if commandErr != nil { + result, err := x.git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") + if err != nil { return "", fmt.Errorf("resolve origin/HEAD: %w", err) } - resolved := strings.TrimSpace(result.stdout) - return plumbing.ReferenceName(resolved).Short(), nil + return result.stdout, nil } -func (x *Repository) upstreamReference(branchName string) (plumbing.ReferenceName, error) { - branchConfig, err := x.Branch(branchName) +func (x *Repository) upstreamReference(branchName string) (referenceName, error) { + branchRef := branchReference(branchName) + result, err := x.git("for-each-ref", "--format=%(refname)%00%(upstream)", string(branchRef)) if err != nil { return "", fmt.Errorf("read branch config for %q: %w", branchName, err) } - if branchConfig.Merge == "" { + for line := range strings.SplitSeq(result.stdout, "\n") { + refName, upstreamRef, found := strings.Cut(line, "\x00") + if !found || refName != string(branchRef) { + continue + } + if upstreamRef == "" { + return x.configuredUpstreamReference(branchName) + } + + return referenceName(upstreamRef), nil + } + + return "", fmt.Errorf("branch %q does not exist", branchName) +} + +func (x *Repository) configuredUpstreamReference(branchName string) (referenceName, error) { + mergeRef, mergeConfigured, err := x.gitConfigValue("branch." + branchName + ".merge") + if err != nil { + return "", err + } + if !mergeConfigured { return "", fmt.Errorf("branch %q has no upstream branch", branchName) } - if branchConfig.Remote == "" || branchConfig.Remote == "." { - return branchConfig.Merge, nil + remote, remoteConfigured, err := x.gitConfigValue("branch." + branchName + ".remote") + if err != nil { + return "", err + } + if !remoteConfigured || remote == "." { + return referenceName(mergeRef), nil + } + + return x.mappedUpstreamReference(branchName, referenceName(mergeRef), remote) +} + +func (x *Repository) mappedUpstreamReference(branchName string, mergeRef referenceName, remote string) (referenceName, error) { + result, err := x.git("rev-parse", "--symbolic-full-name", branchName+"@{upstream}") + if err != nil { + return "", fmt.Errorf( + "branch %q tracks %q at %q, which does not map to a known fetch refspec: %w", + branchName, + mergeRef, + remote, + err, + ) } - return plumbing.NewRemoteReferenceName(branchConfig.Remote, branchConfig.Merge.Short()), nil + return referenceName(result.stdout), nil +} + +func (x *Repository) gitConfigValue(key string) (string, bool, error) { + result, err := x.git("config", "--get", key) + if err == nil { + return result.stdout, true, nil + } + + var exitError *exec.ExitError + if errors.As(err, &exitError) && exitError.ExitCode() == 1 { + return "", false, nil + } + + return "", false, fmt.Errorf("read Git config %q: %w", key, err) +} + +func branchReference(branchName string) referenceName { + return referenceName(branchRefPrefix + branchName) +} + +func shortReference(ref referenceName) string { + refName := string(ref) + switch { + case strings.HasPrefix(refName, branchRefPrefix): + return strings.TrimPrefix(refName, branchRefPrefix) + case strings.HasPrefix(refName, remoteRefPrefix): + return strings.TrimPrefix(refName, remoteRefPrefix) + default: + return refName + } } diff --git a/internal/gitwt/worktree.go b/internal/gitwt/worktree.go index 97c5040..a591af9 100644 --- a/internal/gitwt/worktree.go +++ b/internal/gitwt/worktree.go @@ -6,8 +6,6 @@ import ( "os" "path/filepath" "slices" - - "github.com/go-git/go-git/v5/plumbing" ) type managedWorktree struct { @@ -15,8 +13,8 @@ type managedWorktree struct { Path string DisplayPath string CommitHash string - BranchReference plumbing.ReferenceName - UpstreamRef plumbing.ReferenceName + BranchReference referenceName + UpstreamRef referenceName Status string Main bool Clean bool @@ -37,7 +35,7 @@ func enrichManagedWorktree(repository *Repository, worktree managedWorktree) (ma return managedWorktree{}, err } - wtRepository, err := PlainOpenWithOptions(worktree.Path) + wtRepository, err := openRepository(worktree.Path) if err != nil { return managedWorktree{}, err } @@ -98,7 +96,7 @@ func managedWorktreesFromRepository(repository *Repository) ([]managedWorktree, Path: porcelainWorktree.Path, DisplayPath: currentRelativePath(currentDirectory, porcelainWorktree.Path), CommitHash: porcelainWorktree.CommitHash, - BranchReference: plumbing.ReferenceName(porcelainWorktree.BranchRef), + BranchReference: referenceName(porcelainWorktree.BranchRef), Main: filepath.Clean(porcelainWorktree.Path) == filepath.Clean(mainPath), }) }