Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package lazyworktree for openSUSE:Factory checked in at 2026-04-30 20:30:10 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/lazyworktree (Old) and /work/SRC/openSUSE:Factory/.lazyworktree.new.30200 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "lazyworktree" Thu Apr 30 20:30:10 2026 rev:3 rq:1350141 version:1.45.1 Changes: -------- --- /work/SRC/openSUSE:Factory/lazyworktree/lazyworktree.changes 2026-04-02 17:44:03.994540201 +0200 +++ /work/SRC/openSUSE:Factory/.lazyworktree.new.30200/lazyworktree.changes 2026-04-30 20:30:35.143277426 +0200 @@ -1,0 +2,52 @@ +Thu Apr 30 05:29:09 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 1.45.1: + This release introduces repo-local worktree placement using the + new $LWT_REPO_PATH variable, adds the ability to prune merged + branches without active worktrees, and improves repository path + resolution with URL decoding. + * Features + - Added support for repo-local worktree placement via the + $LWT_REPO_PATH environment variable. This allows users to + store worktrees within the repository itself (e.g., in a + .worktrees folder) while automatically omitting redundant + repository name segments. + - Introduced a new configuration option, prune_stale_branches, + which includes merged local branches that no longer have + active worktrees in the prune checklist. + - Implemented an automated workflow to streamline the merging + of Dependabot pull requests. + * Bug Fixes + - Resolved an issue where repository names with URL-encoded + characters (such as %20) were not decoded before creating + directory paths (#49). + - Improved the identification of Claude CLI processes by + ignoring the exec keyword in shell command wrappers. + - Fixed CI configuration to ensure workflows are correctly + triggered when dependency files (go.mod and go.sum) are + updated. + - Updated the release workflow to verify successful release + creation even if GoReleaser encounters non-critical failures + during secondary steps like AUR pushing. + * Maintenance + - Standardized Git configuration key lookups by automatically + normalizing hyphens to underscores (e.g., lw.worktree-dir now + correctly maps to worktree_dir). + - Replaced the default Dependabot Go configuration with a + custom weekly automated dependency update and auto-merge + workflow. + - Updated documentation with new guidelines for documentation + reviews and agent contribution requirements. + * Dependencies + - Updated project dependencies to their latest versions, + including several Charm libraries (bubbletea, lipgloss, + bubbles, ultraviolet) and standard Go modules (x/term, + x/sys). (#43, #45, #48) + - Upgraded various GitHub Actions to their latest major + versions, including actions/configure-pages, + actions/deploy-pages, actions/upload-pages-artifact, and + peter-evans/create-pull-request. (#42) + * Breaking Changes + - None. + +------------------------------------------------------------------- Old: ---- lazyworktree-1.45.0.obscpio New: ---- lazyworktree-1.45.1.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ lazyworktree.spec ++++++ --- /var/tmp/diff_new_pack.RcRVfx/_old 2026-04-30 20:30:37.415370615 +0200 +++ /var/tmp/diff_new_pack.RcRVfx/_new 2026-04-30 20:30:37.435371429 +0200 @@ -17,7 +17,7 @@ Name: lazyworktree -Version: 1.45.0 +Version: 1.45.1 Release: 0 Summary: Easy Git worktree management for the terminal License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.RcRVfx/_old 2026-04-30 20:30:37.787385740 +0200 +++ /var/tmp/diff_new_pack.RcRVfx/_new 2026-04-30 20:30:37.831387530 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/chmouel/lazyworktree.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v1.45.0</param> + <param name="revision">refs/tags/v1.45.1</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.RcRVfx/_old 2026-04-30 20:30:38.043396149 +0200 +++ /var/tmp/diff_new_pack.RcRVfx/_new 2026-04-30 20:30:38.075397451 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/chmouel/lazyworktree.git</param> - <param name="changesrevision">0d2cb7b5d6f1a57f66620ed34e02b3d9c996a5cd</param></service></servicedata> + <param name="changesrevision">931d43166ed25419350a6b2f7c4e2c5db0aeb91e</param></service></servicedata> (No newline at EOF) ++++++ lazyworktree-1.45.0.obscpio -> lazyworktree-1.45.1.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/AGENTS.md new/lazyworktree-1.45.1/AGENTS.md --- old/lazyworktree-1.45.0/AGENTS.md 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/AGENTS.md 2026-04-28 15:17:12.000000000 +0200 @@ -20,6 +20,9 @@ - internal help text/template in `internal/app/screen/help.go` - generated CLI docs via `make docs-sync` - relevant website docs +- If you change shell integration helpers or completion behaviour, update: + - `shell/README.md` + - the relevant helper in `shell/functions.bash`, `shell/functions.zsh`, or `shell/functions.fish` - Do not add to README.md unless important to ask the human to approve. - Don't ever do commit unless you are being explicitly asked for it. - If you get asked to commit then use this rules: @@ -43,6 +46,17 @@ - Remove any overly casual Americanisms - Keep technical precision whilst maintaining readability +## Documentation review rules + +When reviewing docs: + +- Compare documentation to code, tests, schemas, and CLI help. +- Prefer exact evidence over intuition. +- Separate confirmed drift from likely drift. +- Treat setup, migration, config, and API inaccuracies as high severity. +- Suggest minimal, reviewable doc patches. +- Preserve existing terminology unless the implementation clearly changed. + ### Website documentation (`docs/`) The website is built with MkDocs Material and configured in `mkdocs.yml`. @@ -73,6 +87,7 @@ - `make docs-sync` — regenerates `docs/cli/commands.md` and `docs/cli/flags.md` from source code. - `make docs-check` — runs sync + strict MkDocs build to catch broken links or missing pages. - `make docs-build` / `make docs-serve` — build or preview the site locally. +- `make coverage` — writes `coverage.out` with per-function coverage details for coverage-sensitive changes. - When adding a new page, also add it to `mkdocs.yml` under the appropriate `nav:` section. ## UI diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/Casks/lazyworktree.rb new/lazyworktree-1.45.1/Casks/lazyworktree.rb --- old/lazyworktree-1.45.0/Casks/lazyworktree.rb 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/Casks/lazyworktree.rb 2026-04-28 15:17:12.000000000 +0200 @@ -1,43 +1,44 @@ # This file was generated by GoReleaser. DO NOT EDIT. cask "lazyworktree" do - name "lazyworktree" - desc "lazyworktree - A TUI tool to manage git worktrees" - homepage "https://github.com/chmouel/lazyworktree" - version "1.44.0" - - livecheck do - skip "Auto-generated on release." - end - - binary "lazyworktree" - depends_on formula: [ - "git", - "git-delta", - "lazygit", - "less", - "tmux", - ] + version "1.45.0" on_macos do url "https://github.com/chmouel/lazyworktree/releases/download/v#{version}/lazyworktree_Darwin_all.tar.gz" - sha256 "709d4bf8d076c7116ab1d0cdf6077525050af778b5255c3eaa289b44953342d1" + sha256 "9ebd981b7f78003d67d4eeae36fb945bd6dcc0bab63c32931a32328418712ddf" end on_linux do on_intel do url "https://github.com/chmouel/lazyworktree/releases/download/v#{version}/lazyworktree_Linux_x86_64.tar.gz" - sha256 "ba393e6c208de75440c988c61326b20f6c659bc0b6bc1dbd9b74330ecdaed6a3" + sha256 "3c289aad422e582286fd23683cbe3a2246f126a2381b3d10332a4b4e5a2126be" end on_arm do url "https://github.com/chmouel/lazyworktree/releases/download/v#{version}/lazyworktree_Linux_arm64.tar.gz" - sha256 "d45da9c2244029cd5036b30245e6aca7f4bfe42a0d02dfa112eada7d77912cf4" + sha256 "af367503321eb845028b1a60feda0255ae65172b7fb0dd0029840dfb6b1979e5" end end - caveats do - "On first run, macOS may block the binary. To remove the quarantine attribute:" - " xattr -d com.apple.quarantine #{staged_path}/lazyworktree" + name "lazyworktree" + desc "lazyworktree - A TUI tool to manage git worktrees" + homepage "https://github.com/chmouel/lazyworktree" + + livecheck do + skip "Auto-generated on release." end + depends_on formula: [ + "git", + "git-delta", + "lazygit", + "less", + "tmux", + ] + + binary "lazyworktree" # No zap stanza required + + caveats <<~EOS + On first run, macOS may block the binary. To remove the quarantine attribute: + xattr -d com.apple.quarantine #{staged_path}/lazyworktree + EOS end diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/config.example.yaml new/lazyworktree-1.45.1/config.example.yaml --- old/lazyworktree-1.45.0/config.example.yaml 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/config.example.yaml 2026-04-28 15:17:12.000000000 +0200 @@ -2,7 +2,13 @@ # CORE SETTINGS # ============================================================================ -# Directory where your worktrees will be created +# Directory where your worktrees will be created. +# lazyworktree automatically sets $LWT_REPO_PATH to the git repository root, +# so you can place worktrees inside the repository itself: +# worktree_dir: $LWT_REPO_PATH/.worktrees +# In repo-local mode the <repoName> path segment is omitted, giving: +# <repoRoot>/.worktrees/<worktreeName> +# Remember to add the directory to .gitignore (e.g. echo '.worktrees' >> .gitignore). worktree_dir: ~/.local/share/worktrees # How worktrees are sorted in the list diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/docs/configuration/overview.md new/lazyworktree-1.45.1/docs/configuration/overview.md --- old/lazyworktree-1.45.0/docs/configuration/overview.md 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/docs/configuration/overview.md 2026-04-28 15:17:12.000000000 +0200 @@ -32,7 +32,7 @@ ```bash git config --global lw.theme nord -git config --local lw.sort_mode switched +git config --local lw.sort-mode switched ``` List configured keys: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/docs/configuration/reference.md new/lazyworktree-1.45.1/docs/configuration/reference.md --- old/lazyworktree-1.45.0/docs/configuration/reference.md 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/docs/configuration/reference.md 2026-04-28 15:17:12.000000000 +0200 @@ -5,7 +5,7 @@ <!-- BEGIN GENERATED:config-reference --> | Key | Type | Default | Description | | --- | --- | --- | --- | -| `worktree_dir` | `string` | `none` | Root directory for managed worktrees. | +| `worktree_dir` | `string` | `none` | Root directory for managed worktrees. Supports `$LWT_REPO_PATH` (auto-set to the git repository root) for repo-local placement, e.g. `$LWT_REPO_PATH/.worktrees`. When the directory is inside the repository, the `<repoName>` path segment is omitted automatically. | | `theme` | `string` | `auto-detect` | UI theme selection. | | `icon_set` | `enum(nerd-font-v3\|text)` | `nerd-font-v3` | Icon rendering mode for terminal compatibility. | | `layout` | `enum(default\|top)` | `default` | Pane layout strategy. | @@ -16,6 +16,7 @@ | `ci_auto_refresh` | `bool` | `false` | Enable periodic CI refresh for GitHub repositories. | | `auto_fetch_prs` | `bool` | `false` | Automatically fetch PR/MR data. | | `disable_pr` | `bool` | `false` | Disable PR/MR integration. | +| `prune_stale_branches` | `bool` | `false` | Include merged branches without worktrees in prune. | | `search_auto_select` | `bool` | `false` | Focus filter and auto-select first match. | | `fuzzy_finder_input` | `bool` | `false` | Enable fuzzy helper input in selection dialogues. | | `max_name_length` | `int` | `95` | Maximum displayed worktree name length. | diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/docs/configuration.md new/lazyworktree-1.45.1/docs/configuration.md --- old/lazyworktree-1.45.0/docs/configuration.md 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/docs/configuration.md 2026-04-28 15:17:12.000000000 +0200 @@ -2,6 +2,20 @@ Default worktree location: `~/.local/share/worktrees/<organization>-<repo_name>`. +To keep worktrees inside the repository itself, set `worktree_dir` to use `$LWT_REPO_PATH` — lazyworktree automatically exports this variable to the git repository root before loading config: + +```bash +git config --local lw.worktree-dir '$LWT_REPO_PATH/.worktrees' +``` + +Worktrees are then created at `<repoRoot>/.worktrees/<worktreeName>` (the `<repoName>` segment is omitted when the directory is already inside the repository). + +Add the directory to `.gitignore` so it does not appear in `git status`: + +```bash +echo '.worktrees' >> .gitignore +``` + <div class="lw-callout"> <p><strong>Find settings quickly:</strong> use the map below, then jump to the relevant settings group.</p> </div> @@ -101,12 +115,13 @@ ```bash # Set globally git config --global lw.theme nord -git config --global lw.worktree_dir ~/.local/share/worktrees +git config --global lw.worktree-dir ~/.local/share/worktrees # Set per-repository git config --local lw.theme dracula -git config --local lw.init_commands "link_topsymlinks" -git config --local lw.init_commands "npm install" # Multi-values supported +git config --local lw.worktree-dir '$LWT_REPO_PATH/.worktrees' # Repo-local worktrees +git config --local lw.init-commands "link_topsymlinks" +git config --local lw.init-commands "npm install" # Multi-values supported ``` To view configured values: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/docs/keybindings.md new/lazyworktree-1.45.1/docs/keybindings.md --- old/lazyworktree-1.45.0/docs/keybindings.md 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/docs/keybindings.md 2026-04-28 15:17:12.000000000 +0200 @@ -30,7 +30,7 @@ | `D` | Delete selected worktree | | `d` | View diff in pager (worktree or commit, depending on pane) | | `A` | Absorb worktree into main | -| `X` | Prune merged worktrees (refreshes PR data, checks merge status) | +| `X` | Prune merged worktrees and stale branches (refreshes PR data, checks merge status; stale branches require `prune_stale_branches` config) | | `!` | Run arbitrary command in selected worktree (with command history) | | `v` | View CI checks (Enter opens browser, `Ctrl+v` opens logs in pager) | | `o` | Open PR/MR in browser (or root repo in editor if main branch with merged/closed/no PR) | diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/go.mod new/lazyworktree-1.45.1/go.mod --- old/lazyworktree-1.45.0/go.mod 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/go.mod 2026-04-28 15:17:12.000000000 +0200 @@ -1,27 +1,27 @@ module github.com/chmouel/lazyworktree -go 1.25 +go 1.25.0 require ( - charm.land/bubbles/v2 v2.0.0 - charm.land/bubbletea/v2 v2.0.0 - charm.land/lipgloss/v2 v2.0.0 + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.6 + charm.land/lipgloss/v2 v2.0.3 github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/ansi v0.11.6 + github.com/charmbracelet/x/ansi v0.11.7 github.com/fsnotify/fsnotify v1.9.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/muesli/reflow v0.3.0 github.com/stretchr/testify v1.11.1 - github.com/urfave/cli/v3 v3.6.2 - golang.org/x/term v0.40.0 + github.com/urfave/cli/v3 v3.8.0 + golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.2 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260416161146-9c68a866306c // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -29,14 +29,14 @@ github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/lucasb-eyer/go-colorful v1.3.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-runewidth v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/go.sum new/lazyworktree-1.45.1/go.sum --- old/lazyworktree-1.45.0/go.sum 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/go.sum 2026-04-28 15:17:12.000000000 +0200 @@ -1,25 +1,25 @@ -charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= -charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= -charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ= -charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= -charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= -charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14= +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= +charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 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= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM= -github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= -github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM= -github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/ultraviolet v0.0.0-20260416161146-9c68a866306c h1:a+Q3cOt8vEb6ETG/st32Qjm8R5fdI9wSKb3tqPISnoY= +github.com/charmbracelet/ultraviolet v0.0.0-20260416161146-9c68a866306c/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= @@ -40,13 +40,13 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= @@ -61,19 +61,19 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= -github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= +github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= 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/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/hack/docsync/main.go new/lazyworktree-1.45.1/hack/docsync/main.go --- old/lazyworktree-1.45.0/hack/docsync/main.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/hack/docsync/main.go 2026-04-28 15:17:12.000000000 +0200 @@ -330,6 +330,7 @@ "sort_by_active": "bool (legacy)", "auto_fetch_prs": "bool", "disable_pr": "bool", + "prune_stale_branches": "bool", "auto_refresh": "bool", "ci_auto_refresh": "bool", "refresh_interval": "int", @@ -364,7 +365,7 @@ } descByKey := map[string]string{ - "worktree_dir": "Root directory for managed worktrees.", + "worktree_dir": "Root directory for managed worktrees. Supports `$LWT_REPO_PATH` (auto-set to the git repository root) for repo-local placement, e.g. `$LWT_REPO_PATH/.worktrees`. When the directory is inside the repository, the `<repoName>` path segment is omitted automatically.", "debug_log": "Debug log file path.", "pager": "Pager for command output views.", "ci_script_pager": "Dedicated pager for CI logs.", @@ -376,6 +377,7 @@ "sort_by_active": "Compatibility key for older sort configuration.", "auto_fetch_prs": "Automatically fetch PR/MR data.", "disable_pr": "Disable PR/MR integration.", + "prune_stale_branches": "Include merged branches without worktrees in prune.", "auto_refresh": "Enable background refresh of repository state.", "ci_auto_refresh": "Enable periodic CI refresh for GitHub repositories.", "refresh_interval": "Background refresh cadence in seconds.", @@ -413,6 +415,7 @@ "sort_mode": defaults["SortMode"], "auto_fetch_prs": defaults["AutoFetchPRs"], "disable_pr": "false", + "prune_stale_branches": "false", "auto_refresh": defaults["AutoRefresh"], "ci_auto_refresh": "false", "refresh_interval": defaults["RefreshIntervalSeconds"], @@ -783,6 +786,7 @@ "ci_auto_refresh", "auto_fetch_prs", "disable_pr", + "prune_stale_branches", "search_auto_select", "fuzzy_finder_input", "max_name_length", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/app/app.go new/lazyworktree-1.45.1/internal/app/app.go --- old/lazyworktree-1.45.0/internal/app/app.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/app/app.go 2026-04-28 15:17:12.000000000 +0200 @@ -112,11 +112,12 @@ fetchedAt time.Time } pruneResultMsg struct { - worktrees []*models.WorktreeInfo - err error - pruned int - failed int - orphansDeleted int + worktrees []*models.WorktreeInfo + err error + pruned int + failed int + orphansDeleted int + branchesDeleted int } absorbMergeResultMsg struct { path string diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/app/app_helpers.go new/lazyworktree-1.45.1/internal/app/app_helpers.go --- old/lazyworktree-1.45.0/internal/app/app_helpers.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/app/app_helpers.go 2026-04-28 15:17:12.000000000 +0200 @@ -13,6 +13,7 @@ "charm.land/lipgloss/v2" appscreen "github.com/chmouel/lazyworktree/internal/app/screen" "github.com/chmouel/lazyworktree/internal/app/services" + "github.com/chmouel/lazyworktree/internal/cli" log "github.com/chmouel/lazyworktree/internal/log" "github.com/chmouel/lazyworktree/internal/models" "github.com/chmouel/lazyworktree/internal/utils" @@ -396,7 +397,14 @@ } func (m *Model) getRepoWorktreeDir() string { - return filepath.Join(m.getWorktreeDir(), m.getRepoKey()) + dir := m.getWorktreeDir() + if m.state.services.git != nil { + mainPath := m.state.services.git.GetMainWorktreePath(m.ctx) + if cli.IsRepoLocal(dir, mainPath) { + return dir + } + } + return filepath.Join(dir, m.getRepoKey()) } // normalizePath returns a canonical path for comparison. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/app/handlers_messages_test.go new/lazyworktree-1.45.1/internal/app/handlers_messages_test.go --- old/lazyworktree-1.45.0/internal/app/handlers_messages_test.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/app/handlers_messages_test.go 2026-04-28 15:17:12.000000000 +0200 @@ -417,6 +417,38 @@ } } +func TestHandlePruneResultStaleBranches(t *testing.T) { + cfg := &config.AppConfig{WorktreeDir: t.TempDir()} + m := NewModel(cfg, "") + + msg := pruneResultMsg{worktrees: []*models.WorktreeInfo{}, pruned: 1, branchesDeleted: 3, failed: 0, err: nil} + updated, _ := m.handlePruneResult(msg) + updatedModel := updated.(*Model) + + if !strings.Contains(updatedModel.statusContent, "Pruned 1") { + t.Errorf("expected status to include pruned count, got %q", updatedModel.statusContent) + } + if !strings.Contains(updatedModel.statusContent, "deleted 3 stale branches") { + t.Errorf("expected status to include stale branches count, got %q", updatedModel.statusContent) + } +} + +func TestHandlePruneResultOnlyStaleBranches(t *testing.T) { + cfg := &config.AppConfig{WorktreeDir: t.TempDir()} + m := NewModel(cfg, "") + + msg := pruneResultMsg{worktrees: []*models.WorktreeInfo{}, pruned: 0, branchesDeleted: 2, failed: 0, err: nil} + updated, _ := m.handlePruneResult(msg) + updatedModel := updated.(*Model) + + if strings.Contains(updatedModel.statusContent, "Pruned 0") { + t.Errorf("should not show 'Pruned 0' when no worktrees pruned, got %q", updatedModel.statusContent) + } + if !strings.Contains(updatedModel.statusContent, "deleted 2 stale branches") { + t.Errorf("expected status to include stale branches count, got %q", updatedModel.statusContent) + } +} + func TestHandleAbsorbResultError(t *testing.T) { cfg := &config.AppConfig{WorktreeDir: t.TempDir()} m := NewModel(cfg, "") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/app/messages.go new/lazyworktree-1.45.1/internal/app/messages.go --- old/lazyworktree-1.45.0/internal/app/messages.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/app/messages.go 2026-04-28 15:17:12.000000000 +0200 @@ -211,6 +211,9 @@ if msg.orphansDeleted > 0 { parts = append(parts, fmt.Sprintf("deleted %d orphaned directories", msg.orphansDeleted)) } + if msg.branchesDeleted > 0 { + parts = append(parts, fmt.Sprintf("deleted %d stale branches", msg.branchesDeleted)) + } if msg.failed > 0 { parts = append(parts, fmt.Sprintf("%d failed", msg.failed)) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/app/screen/help.go new/lazyworktree-1.45.1/internal/app/screen/help.go --- old/lazyworktree-1.45.0/internal/app/screen/help.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/app/screen/help.go 2026-04-28 15:17:12.000000000 +0200 @@ -155,7 +155,7 @@ - m: Rename selected worktree - D: Delete selected worktree - A: Absorb worktree into main (merge or rebase based on configuration, then delete) -- X: Prune merged worktrees (auto-refreshes PR data, then checks PR/branch merge status) +- X: Prune merged worktrees and stale branches (auto-refreshes PR data; enable prune_stale_branches to include merged branches without worktrees) - !: Run arbitrary command in selected worktree **{{HELP_BRANCH_NAMING}}Branch Naming** diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/app/services/agent_processes.go new/lazyworktree-1.45.1/internal/app/services/agent_processes.go --- old/lazyworktree-1.45.0/internal/app/services/agent_processes.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/app/services/agent_processes.go 2026-04-28 15:17:12.000000000 +0200 @@ -229,6 +229,9 @@ continue } if shellCommandExpected { + if strings.EqualFold(token, "exec") { + continue + } return isClaudeCLIToken(token) } switch token { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/app/services/agent_processes_test.go new/lazyworktree-1.45.1/internal/app/services/agent_processes_test.go --- old/lazyworktree-1.45.0/internal/app/services/agent_processes_test.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/app/services/agent_processes_test.go 2026-04-28 15:17:12.000000000 +0200 @@ -145,6 +145,18 @@ } } +func TestClassifyAgentProcessMatchesShellExecWrapper(t *testing.T) { + t.Parallel() + + agent, source, ok := classifyAgentProcess("bash", "bash -lc 'exec claude --print'") + if !ok { + t.Fatal("expected shell exec wrapper to be detected") + } + if agent != models.AgentKindClaude || source != "cli" { + t.Fatalf("expected Claude CLI match, got %q %q", agent, source) + } +} + func stringsJoin(lines ...string) string { return strings.Join(lines, "\n") } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/app/services/worktree.go new/lazyworktree-1.45.1/internal/app/services/worktree.go --- old/lazyworktree-1.45.0/internal/app/services/worktree.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/app/services/worktree.go 2026-04-28 15:17:12.000000000 +0200 @@ -36,8 +36,8 @@ // Absorb merges or rebases a worktree into the main branch. Absorb(ctx context.Context, wt *models.WorktreeInfo, mainWorktree *models.WorktreeInfo, mergeMethod string) error - // GetPruneCandidates identifies worktrees that have been merged and are candidates for pruning. - GetPruneCandidates(ctx context.Context, worktrees []*models.WorktreeInfo) ([]PruneCandidate, error) + // GetPruneCandidates identifies worktrees and stale branches that have been merged and are candidates for pruning. + GetPruneCandidates(ctx context.Context, worktrees []*models.WorktreeInfo, includeStaleBranches bool) ([]PruneCandidate, error) // ExecuteCommands runs a list of shell commands in the specified directory. ExecuteCommands(ctx context.Context, commands []string, cwd string, env map[string]string) error @@ -60,10 +60,11 @@ Env map[string]string } -// PruneCandidate represents a worktree that is a candidate for pruning. +// PruneCandidate represents a worktree or stale branch that is a candidate for pruning. type PruneCandidate struct { - Worktree *models.WorktreeInfo - Source string // "pr", "git", or "both" + Worktree *models.WorktreeInfo // nil for branch-only candidates + Branch string // branch name (always set) + Source string // "pr", "git", or "both" } // GitService defines the subset of git operations needed by WorktreeService. @@ -232,11 +233,15 @@ return nil } -func (s *worktreeService) GetPruneCandidates(ctx context.Context, worktrees []*models.WorktreeInfo) ([]PruneCandidate, error) { +func (s *worktreeService) GetPruneCandidates(ctx context.Context, worktrees []*models.WorktreeInfo, includeStaleBranches bool) ([]PruneCandidate, error) { mainBranch := s.git.GetMainBranch(ctx) wtBranches := make(map[string]*models.WorktreeInfo) + checkedOutBranches := make(map[string]struct{}) for _, wt := range worktrees { + if wt.Branch != "" { + checkedOutBranches[wt.Branch] = struct{}{} + } if !wt.IsMain { wtBranches[wt.Branch] = wt } @@ -250,7 +255,7 @@ continue } if wt.PR != nil && strings.EqualFold(wt.PR.State, "MERGED") { - candidateMap[wt.Branch] = PruneCandidate{Worktree: wt, Source: "pr"} + candidateMap[wt.Branch] = PruneCandidate{Worktree: wt, Branch: wt.Branch, Source: "pr"} } } @@ -262,7 +267,21 @@ existing.Source = "both" candidateMap[branch] = existing } else { - candidateMap[branch] = PruneCandidate{Worktree: wt, Source: "git"} + candidateMap[branch] = PruneCandidate{Worktree: wt, Branch: branch, Source: "git"} + } + } + } + + // 3. Branch-only detection: merged branches with no worktree + if includeStaleBranches { + for _, branch := range mergedBranches { + if _, checkedOut := checkedOutBranches[branch]; checkedOut { + continue + } + if _, exists := wtBranches[branch]; !exists { + if _, found := candidateMap[branch]; !found { + candidateMap[branch] = PruneCandidate{Worktree: nil, Branch: branch, Source: "git"} + } } } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/app/services/worktree_test.go new/lazyworktree-1.45.1/internal/app/services/worktree_test.go --- old/lazyworktree-1.45.0/internal/app/services/worktree_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/app/services/worktree_test.go 2026-04-28 15:17:12.000000000 +0200 @@ -0,0 +1,122 @@ +package services + +import ( + "context" + "testing" + + "github.com/chmouel/lazyworktree/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockGitService struct { + mainBranch string + mergedBranches []string +} + +func (m *mockGitService) RunGit(_ context.Context, _ []string, _ string, _ []int, _, _ bool) string { + return "" +} + +func (m *mockGitService) RunCommandChecked(_ context.Context, _ []string, _, _ string) bool { + return true +} + +func (m *mockGitService) GetMainBranch(_ context.Context) string { + return m.mainBranch +} + +func (m *mockGitService) GetMergedBranches(_ context.Context, _ string) []string { + return m.mergedBranches +} + +func (m *mockGitService) RenameWorktree(_ context.Context, _, _, _, _ string) bool { + return true +} + +func (m *mockGitService) ExecuteCommands(_ context.Context, _ []string, _ string, _ map[string]string) error { + return nil +} + +func (m *mockGitService) RunGitWithCombinedOutput(_ context.Context, _ []string, _ string, _ map[string]string) ([]byte, error) { + return nil, nil +} + +func TestGetPruneCandidatesWithStaleBranches(t *testing.T) { + git := &mockGitService{ + mainBranch: "main", + mergedBranches: []string{"feature-a", "feature-b", "stale-branch"}, + } + svc := NewWorktreeService(git) + + worktrees := []*models.WorktreeInfo{ + {Path: "/repo/main", Branch: "main", IsMain: true}, + {Path: "/repo/feature-a", Branch: "feature-a"}, + } + + // Without stale branches + candidates, err := svc.GetPruneCandidates(context.Background(), worktrees, false) + require.NoError(t, err) + + // Only feature-a should be a candidate (has worktree and is merged) + assert.Len(t, candidates, 1) + assert.Equal(t, "feature-a", candidates[0].Branch) + assert.NotNil(t, candidates[0].Worktree) + + // With stale branches + candidates, err = svc.GetPruneCandidates(context.Background(), worktrees, true) + require.NoError(t, err) + + // feature-a (with worktree) + feature-b and stale-branch (branch-only) + assert.Len(t, candidates, 3) + + branchMap := make(map[string]PruneCandidate) + for _, c := range candidates { + branchMap[c.Branch] = c + } + + assert.NotNil(t, branchMap["feature-a"].Worktree) + assert.Nil(t, branchMap["feature-b"].Worktree) + assert.Equal(t, "git", branchMap["feature-b"].Source) + assert.Nil(t, branchMap["stale-branch"].Worktree) + assert.Equal(t, "git", branchMap["stale-branch"].Source) +} + +func TestGetPruneCandidatesPRMerged(t *testing.T) { + git := &mockGitService{ + mainBranch: "main", + mergedBranches: []string{"feature-a"}, + } + svc := NewWorktreeService(git) + + worktrees := []*models.WorktreeInfo{ + {Path: "/repo/main", Branch: "main", IsMain: true}, + {Path: "/repo/feature-a", Branch: "feature-a", PR: &models.PRInfo{State: "MERGED"}}, + } + + candidates, err := svc.GetPruneCandidates(context.Background(), worktrees, false) + require.NoError(t, err) + + assert.Len(t, candidates, 1) + assert.Equal(t, "both", candidates[0].Source) + assert.Equal(t, "feature-a", candidates[0].Branch) +} + +func TestGetPruneCandidatesExcludesCheckedOutMainWorktreeBranch(t *testing.T) { + git := &mockGitService{ + mainBranch: "main", + mergedBranches: []string{"feature-root", "stale-branch"}, + } + svc := NewWorktreeService(git) + + worktrees := []*models.WorktreeInfo{ + {Path: "/repo", Branch: "feature-root", IsMain: true}, + } + + candidates, err := svc.GetPruneCandidates(context.Background(), worktrees, true) + require.NoError(t, err) + + assert.Len(t, candidates, 1) + assert.Equal(t, "stale-branch", candidates[0].Branch) + assert.Nil(t, candidates[0].Worktree) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/app/worktree_operations.go new/lazyworktree-1.45.1/internal/app/worktree_operations.go --- old/lazyworktree-1.45.0/internal/app/worktree_operations.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/app/worktree_operations.go 2026-04-28 15:17:12.000000000 +0200 @@ -714,7 +714,11 @@ mainBranch := m.state.services.git.GetMainBranch(m.ctx) wtBranches := make(map[string]*models.WorktreeInfo) + checkedOutBranches := make(map[string]struct{}) for _, wt := range m.state.data.worktrees { + if wt.Branch != "" { + checkedOutBranches[wt.Branch] = struct{}{} + } if !wt.IsMain { wtBranches[wt.Branch] = wt } @@ -750,7 +754,21 @@ } } - // 3. Detect orphaned directories (exist on disk but not in git worktree list) + // 3. Branch-only detection: merged branches with no worktree (config-gated) + if m.config.PruneStaleBranches { + for _, branch := range mergedBranches { + if _, checkedOut := checkedOutBranches[branch]; checkedOut { + continue + } + if _, exists := wtBranches[branch]; !exists { + if _, found := candidateMap[branch]; !found { + candidateMap[branch] = candidate{wt: nil, source: "git"} + } + } + } + } + + // 4. Detect orphaned directories (exist on disk but not in git worktree list) orphanedDirs := m.findOrphanedWorktreeDirs() if len(candidateMap) == 0 && len(orphanedDirs) == 0 { @@ -761,8 +779,19 @@ // Build checklist items (pre-check clean worktrees, uncheck dirty ones) items := make([]appscreen.ChecklistItem, 0, len(candidateMap)+len(orphanedDirs)) - // Add merged worktrees + // Add merged worktrees and stale branches for branch, info := range candidateMap { + if info.wt == nil { + // Branch-only candidate (merged, no worktree) + items = append(items, appscreen.ChecklistItem{ + ID: "branch:" + branch, + Label: branch, + Description: fmt.Sprintf("Branch: %s (merged, no worktree)", branch), + Checked: false, // Require explicit selection + }) + continue + } + // Get worktree name from path wtName := filepath.Base(info.wt.Path) @@ -823,12 +852,15 @@ return nil } - // Separate worktrees from orphaned directories + // Separate worktrees, stale branches, and orphaned directories toPrune := make([]*models.WorktreeInfo, 0, len(selected)) orphansToDelete := make([]string, 0) + branchesToDelete := make([]string, 0) for _, item := range selected { if orphanPath, isOrphan := strings.CutPrefix(item.ID, "orphan:"); isOrphan { orphansToDelete = append(orphansToDelete, orphanPath) + } else if branchName, isBranch := strings.CutPrefix(item.ID, "branch:"); isBranch { + branchesToDelete = append(branchesToDelete, branchName) } else if wt, exists := wtBranches[item.ID]; exists { toPrune = append(toPrune, wt) } @@ -863,6 +895,16 @@ } } + // Delete stale branches (merged, no worktree) + branchesDeleted := 0 + for _, branch := range branchesToDelete { + if m.state.services.git.RunCommandChecked(m.ctx, []string{"git", "branch", "-D", branch}, "", fmt.Sprintf("Failed to delete branch %s", branch)) { + branchesDeleted++ + } else { + failed++ + } + } + // Delete orphaned directories // Re-fetch valid paths to ensure we have current state validPaths := m.getValidWorktreePaths() @@ -893,11 +935,12 @@ worktrees, err := m.state.services.git.GetWorktrees(m.ctx) return pruneResultMsg{ - worktrees: worktrees, - err: err, - pruned: pruned, - failed: failed, - orphansDeleted: orphansDeleted, + worktrees: worktrees, + err: err, + pruned: pruned, + failed: failed, + orphansDeleted: orphansDeleted, + branchesDeleted: branchesDeleted, } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/app/worktree_operations_test.go new/lazyworktree-1.45.1/internal/app/worktree_operations_test.go --- old/lazyworktree-1.45.0/internal/app/worktree_operations_test.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/app/worktree_operations_test.go 2026-04-28 15:17:12.000000000 +0200 @@ -647,6 +647,54 @@ } } +func TestPerformMergedWorktreeCheckExcludesCheckedOutMainWorktreeBranch(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + runGit(t, repo, "config", "user.email", "[email protected]") + runGit(t, repo, "config", "user.name", "Test User") + runGit(t, repo, "config", "commit.gpgsign", "false") + + filePath := filepath.Join(repo, "file.txt") + if err := os.WriteFile(filePath, []byte("base\n"), 0o600); err != nil { + t.Fatalf("write initial file: %v", err) + } + runGit(t, repo, "add", "file.txt") + runGit(t, repo, "commit", "-m", "Initial commit") + + runGit(t, repo, "checkout", "-b", "feature-root") + if err := os.WriteFile(filePath, []byte("feature\n"), 0o600); err != nil { + t.Fatalf("write feature file: %v", err) + } + runGit(t, repo, "commit", "-am", "Feature commit") + runGit(t, repo, "checkout", "main") + runGit(t, repo, "merge", "--no-ff", "--no-edit", "feature-root") + runGit(t, repo, "checkout", "feature-root") + + withCwd(t, repo) + + cfg := &config.AppConfig{ + WorktreeDir: t.TempDir(), + PruneStaleBranches: true, + } + m := NewModel(cfg, "") + m.state.data.worktrees = []*models.WorktreeInfo{ + {Path: repo, Branch: "feature-root", IsMain: true}, + } + + cmd := m.performMergedWorktreeCheck() + if cmd != nil { + t.Fatal("expected no checklist command when only the checked-out main worktree branch is merged") + } + if !m.state.ui.screenManager.IsActive() || m.state.ui.screenManager.Type() != appscreen.TypeInfo { + t.Fatalf("expected info screen, got active=%v type=%v", m.state.ui.screenManager.IsActive(), m.state.ui.screenManager.Type()) + } + + infoScr := m.state.ui.screenManager.Current().(*appscreen.InfoScreen) + if infoScr.Message != "No merged worktrees or orphaned directories to prune." { + t.Fatalf("unexpected info modal: %q", infoScr.Message) + } +} + func TestShowPruneMergedUnknownHost(t *testing.T) { // Test that showPruneMerged skips PR fetch for unknown hosts cfg := &config.AppConfig{ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/bootstrap/bootstrap.go new/lazyworktree-1.45.1/internal/bootstrap/bootstrap.go --- old/lazyworktree-1.45.0/internal/bootstrap/bootstrap.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/bootstrap/bootstrap.go 2026-04-28 15:17:12.000000000 +0200 @@ -5,10 +5,12 @@ "context" "fmt" "os" + "os/exec" "path/filepath" "slices" "sort" "strings" + "sync" tea "charm.land/bubbletea/v2" "github.com/chmouel/lazyworktree/internal/app" @@ -20,6 +22,29 @@ "github.com/urfave/cli/v3" ) +// gitToplevel returns the root of the current git repository, or "" if not in one. +func gitToplevel() string { + out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +var initRepoPathOnce sync.Once + +// ensureRepoPath sets $LWT_REPO_PATH lazily so the git probe only runs when +// config is actually loaded (skipped for --version, shell completion, etc.). +func ensureRepoPath() { + initRepoPathOnce.Do(func() { + if root := gitToplevel(); root != "" { + _ = os.Setenv("LWT_REPO_PATH", root) + } else { + _ = os.Unsetenv("LWT_REPO_PATH") + } + }) +} + // Run constructs the CLI application and executes it. // It returns an exit code suitable for os.Exit. func Run(args []string) int { @@ -90,6 +115,8 @@ } func runTUI(_ context.Context, cmd *cli.Command) error { + ensureRepoPath() + if debugLog := cmd.String("debug-log"); debugLog != "" { expanded, err := utils.ExpandPath(debugLog) if err == nil { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/bootstrap/commands.go new/lazyworktree-1.45.1/internal/bootstrap/commands.go --- old/lazyworktree-1.45.0/internal/bootstrap/commands.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/bootstrap/commands.go 2026-04-28 15:17:12.000000000 +0200 @@ -1174,7 +1174,7 @@ nonMain = append(nonMain, wt) } } - if target, fErr := cli.FindWorktreeByPathOrName(worktreePath, nonMain, cfg.WorktreeDir, repoKey); fErr == nil { + if target, fErr := cli.FindWorktreeByPathOrName(worktreePath, nonMain, cfg.WorktreeDir, repoKey, gitSvc.GetMainWorktreePath(ctx)); fErr == nil { preDeleteName = filepath.Base(target.Path) preDeletePath = target.Path willDeleteBranch = deleteBranch && preDeleteName == target.Branch @@ -1260,7 +1260,7 @@ nonMain = append(nonMain, wt) } } - if target, fErr := cli.FindWorktreeByPathOrName(worktreePath, nonMain, cfg.WorktreeDir, repoKey); fErr == nil { + if target, fErr := cli.FindWorktreeByPathOrName(worktreePath, nonMain, cfg.WorktreeDir, repoKey, gitSvc.GetMainWorktreePath(ctx)); fErr == nil { oldName = filepath.Base(target.Path) oldPath = target.Path sanitizedNewName = utils.SanitizeBranchName(newName, 100) @@ -1521,7 +1521,7 @@ var targetWorktree *models.WorktreeInfo if workspace != "" { // User provided workspace flag - targetWorktree, err = cli.FindWorktreeByPathOrName(workspace, worktrees, cfg.WorktreeDir, gitSvc.ResolveRepoName(ctx)) + targetWorktree, err = cli.FindWorktreeByPathOrName(workspace, worktrees, cfg.WorktreeDir, gitSvc.ResolveRepoName(ctx), gitSvc.GetMainWorktreePath(ctx)) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return err diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/bootstrap/subcommands.go new/lazyworktree-1.45.1/internal/bootstrap/subcommands.go --- old/lazyworktree-1.45.0/internal/bootstrap/subcommands.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/bootstrap/subcommands.go 2026-04-28 15:17:12.000000000 +0200 @@ -10,6 +10,8 @@ // loadCLIConfig loads and configures the application configuration for CLI mode. func loadCLIConfig(configFileFlag, worktreeDirFlag string, configOverrides []string) (*config.AppConfig, error) { + ensureRepoPath() + cfg, err := config.LoadConfig(configFileFlag) if err != nil { fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/cli/operations.go new/lazyworktree-1.45.1/internal/cli/operations.go --- old/lazyworktree-1.45.0/internal/cli/operations.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/cli/operations.go 2026-04-28 15:17:12.000000000 +0200 @@ -61,6 +61,27 @@ var _ gitService = (*git.Service)(nil) +// IsRepoLocal reports whether worktreeDir is inside mainWorktreePath, +// indicating that worktrees are stored within the repository itself. +func IsRepoLocal(worktreeDir, mainWorktreePath string) bool { + if mainWorktreePath == "" || worktreeDir == "" { + return false + } + return strings.HasPrefix(filepath.Clean(worktreeDir)+string(filepath.Separator), + filepath.Clean(mainWorktreePath)+string(filepath.Separator)) +} + +// resolveWorktreeBaseDir returns the parent directory for a new worktree. +// When worktreeDir is already inside mainWorktreePath (repo-local mode), the +// repoName segment is omitted — the dir is already scoped to one repo. +// Otherwise the classic <worktreeDir>/<repoName> structure is used. +func resolveWorktreeBaseDir(worktreeDir, mainWorktreePath, repoName string) string { + if IsRepoLocal(worktreeDir, mainWorktreePath) { + return worktreeDir + } + return filepath.Join(worktreeDir, repoName) +} + // CreateFromBranch creates a worktree from a branch name. func CreateFromBranch(ctx context.Context, gitSvc gitService, cfg *config.AppConfig, branchName, worktreeName string, withChange, silent bool) (string, error) { return CreateFromBranchWithFS(ctx, gitSvc, cfg, branchName, worktreeName, withChange, silent, DefaultFS) @@ -92,13 +113,14 @@ } // Construct target path based on worktree name + mainWorktreePath := gitSvc.GetMainWorktreePath(ctx) repoName := gitSvc.ResolveRepoName(ctx) // Generate random name if not provided, or validate user-provided name if worktreeName == "" { // Generate random name with retry for uniqueness sanitizedBranch := utils.SanitizeBranchName(branchName, 50) - worktreeName = generateUniqueWorktreeNameFS(cfg, repoName, sanitizedBranch, fs) + worktreeName = generateUniqueWorktreeNameFS(cfg, mainWorktreePath, repoName, sanitizedBranch, fs) } else { // Validate and sanitise user-provided name sanitised := utils.SanitizeBranchName(worktreeName, 100) @@ -108,7 +130,8 @@ worktreeName = sanitised } - targetPath := filepath.Join(cfg.WorktreeDir, repoName, worktreeName) + base := resolveWorktreeBaseDir(cfg.WorktreeDir, mainWorktreePath, repoName) + targetPath := filepath.Join(base, worktreeName) // Check for path conflicts if _, err := fs.Stat(targetPath); err == nil { @@ -190,13 +213,14 @@ // generateUniqueWorktreeNameFS generates a unique worktree name with retries. // Format: <branch>-<random-adjective>-<random-noun> // Retries up to 10 times if path already exists. -func generateUniqueWorktreeNameFS(cfg *config.AppConfig, repoName, branchName string, fs OSFilesystem) string { +func generateUniqueWorktreeNameFS(cfg *config.AppConfig, mainWorktreePath, repoName, branchName string, fs OSFilesystem) string { const maxRetries = 10 + base := resolveWorktreeBaseDir(cfg.WorktreeDir, mainWorktreePath, repoName) for range maxRetries { randomPart := utils.RandomBranchName() candidate := fmt.Sprintf("%s-%s", branchName, randomPart) - targetPath := filepath.Join(cfg.WorktreeDir, repoName, candidate) + targetPath := filepath.Join(base, candidate) // Check if path exists if _, err := fs.Stat(targetPath); os.IsNotExist(err) { @@ -261,6 +285,7 @@ return "", fmt.Errorf("branch %q is already checked out in worktree %q", localBranch, worktreePath) } + mainWorktreePath := gitSvc.GetMainWorktreePath(ctx) repoName := gitSvc.ResolveRepoName(ctx) if noWorkspace { @@ -271,7 +296,8 @@ } // Construct target path - targetPath := filepath.Join(cfg.WorktreeDir, repoName, worktreeName) + base := resolveWorktreeBaseDir(cfg.WorktreeDir, mainWorktreePath, repoName) + targetPath := filepath.Join(base, worktreeName) // Check for path conflicts if _, err := fs.Stat(targetPath); err == nil { @@ -390,8 +416,10 @@ } // Construct target path + mainWorktreePath := gitSvc.GetMainWorktreePath(ctx) repoName := gitSvc.ResolveRepoName(ctx) - targetPath := filepath.Join(cfg.WorktreeDir, repoName, branchName) + base := resolveWorktreeBaseDir(cfg.WorktreeDir, mainWorktreePath, repoName) + targetPath := filepath.Join(base, branchName) // Check for path conflicts if _, err := fs.Stat(targetPath); err == nil { @@ -456,7 +484,7 @@ } // Find the worktree to delete - selectedWorktree, err := FindWorktreeByPathOrName(worktreePath, nonMainWorktrees, cfg.WorktreeDir, gitSvc.ResolveRepoName(ctx)) + selectedWorktree, err := FindWorktreeByPathOrName(worktreePath, nonMainWorktrees, cfg.WorktreeDir, gitSvc.ResolveRepoName(ctx), gitSvc.GetMainWorktreePath(ctx)) if err != nil { return err } @@ -538,7 +566,7 @@ return fmt.Errorf("invalid new name: must contain at least one alphanumeric character") } - selectedWorktree, err := FindWorktreeByPathOrName(worktreePath, nonMainWorktrees, cfg.WorktreeDir, gitSvc.ResolveRepoName(ctx)) + selectedWorktree, err := FindWorktreeByPathOrName(worktreePath, nonMainWorktrees, cfg.WorktreeDir, gitSvc.ResolveRepoName(ctx), gitSvc.GetMainWorktreePath(ctx)) if err != nil { return err } @@ -567,7 +595,7 @@ } // FindWorktreeByPathOrName finds a worktree by its path or name. -func FindWorktreeByPathOrName(pathOrName string, worktrees []*models.WorktreeInfo, worktreeDir, repoName string) (*models.WorktreeInfo, error) { +func FindWorktreeByPathOrName(pathOrName string, worktrees []*models.WorktreeInfo, worktreeDir, repoName, mainWorktreePath string) (*models.WorktreeInfo, error) { // Try to match by exact path for _, wt := range worktrees { if wt.Path == pathOrName { @@ -589,8 +617,8 @@ } } - // Try to construct the path from worktree name and match - constructedPath := filepath.Join(worktreeDir, repoName, pathOrName) + // Try to construct the path from worktree name and match (handles both global and repo-local modes) + constructedPath := filepath.Join(resolveWorktreeBaseDir(worktreeDir, mainWorktreePath, repoName), pathOrName) for _, wt := range worktrees { if wt.Path == constructedPath { return wt, nil @@ -922,7 +950,7 @@ } } else { repoName := gitSvc.ResolveRepoName(ctx) - target, err = FindWorktreeByPathOrName(worktreePathOrName, worktrees, cfg.WorktreeDir, repoName) + target, err = FindWorktreeByPathOrName(worktreePathOrName, worktrees, cfg.WorktreeDir, repoName, gitSvc.GetMainWorktreePath(ctx)) if err != nil { return nil, err } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/cli/operations_test.go new/lazyworktree-1.45.1/internal/cli/operations_test.go --- old/lazyworktree-1.45.0/internal/cli/operations_test.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/cli/operations_test.go 2026-04-28 15:17:12.000000000 +0200 @@ -221,7 +221,7 @@ for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - found, err := FindWorktreeByPathOrName(tt.pathOrName, worktrees, worktreeDir, repoName) + found, err := FindWorktreeByPathOrName(tt.pathOrName, worktrees, worktreeDir, repoName, "") if tt.wantErr { if err == nil { t.Fatalf("expected error") @@ -238,6 +238,142 @@ } } +func TestResolveWorktreeBaseDir(t *testing.T) { + t.Parallel() + + globalWorktreeDir := filepath.Join(string(filepath.Separator), "home", "user", ".local", "share", "worktrees") + mainWorktreePath := filepath.Join(string(filepath.Separator), "home", "user", "code", "myrepo") + repoLocalWorktreeDir := filepath.Join(mainWorktreePath, ".worktrees") + worktreesRootDir := filepath.Join(string(filepath.Separator), "worktrees") + similarPrefixWorktreeDir := filepath.Join(string(filepath.Separator), "home", "user", "code", "myrepo-other", ".worktrees") + + tests := []struct { + name string + worktreeDir string + mainWorktreePath string + repoName string + want string + }{ + { + name: "global mode: absolute dir outside repo", + worktreeDir: globalWorktreeDir, + mainWorktreePath: mainWorktreePath, + repoName: "org-myrepo", + want: filepath.Join(globalWorktreeDir, "org-myrepo"), + }, + { + name: "repo-local mode: dir inside main worktree", + worktreeDir: repoLocalWorktreeDir, + mainWorktreePath: mainWorktreePath, + repoName: "org-myrepo", + want: repoLocalWorktreeDir, + }, + { + name: "repo-local mode: dir equals main worktree path", + worktreeDir: mainWorktreePath, + mainWorktreePath: mainWorktreePath, + repoName: "org-myrepo", + want: mainWorktreePath, + }, + { + name: "empty mainWorktreePath falls back to global", + worktreeDir: worktreesRootDir, + mainWorktreePath: "", + repoName: "myrepo", + want: filepath.Join(worktreesRootDir, "myrepo"), + }, + { + name: "similar prefix does not trigger repo-local", + worktreeDir: similarPrefixWorktreeDir, + mainWorktreePath: mainWorktreePath, + repoName: "org-myrepo", + want: filepath.Join(similarPrefixWorktreeDir, "org-myrepo"), + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := resolveWorktreeBaseDir(tt.worktreeDir, tt.mainWorktreePath, tt.repoName) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsRepoLocal(t *testing.T) { + t.Parallel() + + mainWorktreePath := filepath.Join(string(filepath.Separator), "home", "user", "code", "myrepo") + + tests := []struct { + name string + worktreeDir string + mainWorktreePath string + want bool + }{ + { + name: "inside main worktree", + worktreeDir: filepath.Join(mainWorktreePath, ".worktrees"), + mainWorktreePath: mainWorktreePath, + want: true, + }, + { + name: "equals main worktree path", + worktreeDir: mainWorktreePath, + mainWorktreePath: mainWorktreePath, + want: true, + }, + { + name: "outside main worktree", + worktreeDir: filepath.Join(string(filepath.Separator), "home", "user", ".local", "share", "worktrees"), + mainWorktreePath: mainWorktreePath, + want: false, + }, + { + name: "similar prefix is not repo-local", + worktreeDir: filepath.Join(string(filepath.Separator), "home", "user", "code", "myrepo-other", ".worktrees"), + mainWorktreePath: mainWorktreePath, + want: false, + }, + { + name: "empty mainWorktreePath", + worktreeDir: filepath.Join(string(filepath.Separator), "worktrees"), + mainWorktreePath: "", + want: false, + }, + { + name: "empty worktreeDir", + worktreeDir: "", + mainWorktreePath: mainWorktreePath, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := IsRepoLocal(tt.worktreeDir, tt.mainWorktreePath) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFindWorktreeByPathOrNameRepoLocal(t *testing.T) { + t.Parallel() + + mainPath := filepath.Join(string(filepath.Separator), "home", "user", "code", "myrepo") + worktreeDir := filepath.Join(mainPath, ".worktrees") + repoName := "org-myrepo" + + wtFeature := &models.WorktreeInfo{Path: filepath.Join(worktreeDir, "feature"), Branch: "feature"} + worktrees := []*models.WorktreeInfo{wtFeature} + + found, err := FindWorktreeByPathOrName("feature", worktrees, worktreeDir, repoName, mainPath) + require.NoError(t, err) + assert.Equal(t, wtFeature, found) +} + func TestBranchExists(t *testing.T) { t.Parallel() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/config/config.go new/lazyworktree-1.45.1/internal/config/config.go --- old/lazyworktree-1.45.0/internal/config/config.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/config/config.go 2026-04-28 15:17:12.000000000 +0200 @@ -65,6 +65,7 @@ PRBranchNameTemplate string // Template for PR branch names with placeholders: {number}, {title}, {generated}, {pr_author} (default: "pr-{number}-{title}") SessionPrefix string // Prefix for tmux/zellij session names (default: "wt-") Layout string // Pane arrangement: "default" or "top" (default: "default") + PruneStaleBranches bool // Include merged branches without worktrees in prune (default: false) PaletteMRU bool // Enable MRU sorting for command palette (default: false) PaletteMRULimit int // Number of MRU items to show (default: 5) AgentSessionClaudeRoot string // Custom root for Claude transcript discovery (default: ~/.claude/projects) @@ -236,6 +237,7 @@ cfg.RefreshIntervalSeconds = coerceInt(data["refresh_interval"], cfg.RefreshIntervalSeconds) cfg.SearchAutoSelect = coerceBool(data["search_auto_select"], false) cfg.FuzzyFinderInput = coerceBool(data["fuzzy_finder_input"], false) + cfg.PruneStaleBranches = coerceBool(data["prune_stale_branches"], false) if iconSet, ok := data["icon_set"].(string); ok { iconSet = strings.ToLower(strings.TrimSpace(iconSet)) @@ -629,6 +631,9 @@ if _, ok := overrideData["fuzzy_finder_input"]; ok { cfg.FuzzyFinderInput = overrideCfg.FuzzyFinderInput } + if _, ok := overrideData["prune_stale_branches"]; ok { + cfg.PruneStaleBranches = overrideCfg.PruneStaleBranches + } if _, ok := overrideData["icon_set"]; ok { cfg.IconSet = overrideCfg.IconSet } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/config/config_test.go new/lazyworktree-1.45.1/internal/config/config_test.go --- old/lazyworktree-1.45.0/internal/config/config_test.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/config/config_test.go 2026-04-28 15:17:12.000000000 +0200 @@ -462,6 +462,22 @@ }, }, { + name: "prune_stale_branches true", + data: map[string]interface{}{ + "prune_stale_branches": true, + }, + validate: func(t *testing.T, cfg *AppConfig) { + assert.True(t, cfg.PruneStaleBranches) + }, + }, + { + name: "prune_stale_branches false by default", + data: map[string]interface{}{}, + validate: func(t *testing.T, cfg *AppConfig) { + assert.False(t, cfg.PruneStaleBranches) + }, + }, + { name: "search_auto_select true", data: map[string]interface{}{ "search_auto_select": true, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/config/gitconfig.go new/lazyworktree-1.45.1/internal/config/gitconfig.go --- old/lazyworktree-1.45.0/internal/config/gitconfig.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/config/gitconfig.go 2026-04-28 15:17:12.000000000 +0200 @@ -54,7 +54,7 @@ continue } - key := strings.TrimPrefix(parts[0], "lw.") + key := strings.ReplaceAll(strings.TrimPrefix(parts[0], "lw."), "-", "_") value := parts[1] // Git config can have multi-values for same key @@ -111,13 +111,11 @@ // loadGitConfig reads git config values and returns map for parseConfig. func loadGitConfig(globalOnly bool, repoPath string) (map[string]any, error) { - args := []string{"config", "--get-regexp", "^lw\\."} - + scope := "--local" if globalOnly { - args = append(args, "--global") - } else { - args = append(args, "--local") + scope = "--global" } + args := []string{"config", scope, "--get-regexp", "^lw\\."} output, err := runGitConfig(args, repoPath) if err != nil { @@ -182,7 +180,7 @@ return nil, fmt.Errorf("config override key must start with 'lw.': %q", fullKey) } - key := strings.TrimPrefix(fullKey, "lw.") + key := strings.ReplaceAll(strings.TrimPrefix(fullKey, "lw."), "-", "_") if key == "" { return nil, fmt.Errorf("empty config key in override: %q", override) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/config/gitconfig_test.go new/lazyworktree-1.45.1/internal/config/gitconfig_test.go --- old/lazyworktree-1.45.0/internal/config/gitconfig_test.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/config/gitconfig_test.go 2026-04-28 15:17:12.000000000 +0200 @@ -59,6 +59,17 @@ expected: map[string][]string{}, }, { + name: "hyphens normalised to underscores", + output: `lw.worktree-dir /path/to/dir +lw.auto-fetch-prs true +lw.init-commands npm install`, + expected: map[string][]string{ + "worktree_dir": {"/path/to/dir"}, + "auto_fetch_prs": {"true"}, + "init_commands": {"npm install"}, + }, + }, + { name: "mixed valid and empty lines", output: `lw.theme nord @@ -240,6 +251,14 @@ errMsg: "empty config key", }, { + name: "hyphenated keys normalised", + overrides: []string{"lw.worktree-dir=/path", "lw.auto-fetch-prs=true"}, + expected: map[string]any{ + "worktree_dir": "/path", + "auto_fetch_prs": "true", + }, + }, + { name: "empty value is allowed", overrides: []string{"lw.theme="}, expected: map[string]any{ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/git/service_host.go new/lazyworktree-1.45.1/internal/git/service_host.go --- old/lazyworktree-1.45.0/internal/git/service_host.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/git/service_host.go 2026-04-28 15:17:12.000000000 +0200 @@ -5,6 +5,7 @@ "crypto/sha256" "encoding/json" "fmt" + "net/url" "regexp" "strings" ) @@ -104,7 +105,11 @@ return "unknown" } - return strings.TrimSuffix(repoName, ".git") + repoName = strings.TrimSuffix(repoName, ".git") + if decoded, err := url.PathUnescape(repoName); err == nil { + repoName = decoded + } + return repoName } // localRepoKey builds a stable, compact cache key when no remote name is available. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/internal/git/service_host_test.go new/lazyworktree-1.45.1/internal/git/service_host_test.go --- old/lazyworktree-1.45.0/internal/git/service_host_test.go 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/internal/git/service_host_test.go 2026-04-28 15:17:12.000000000 +0200 @@ -127,6 +127,29 @@ assert.Equal(t, "owner/repo", service.ResolveRepoName(context.Background())) }) + t.Run("url-encoded remote segments are decoded", func(t *testing.T) { + cases := []struct { + name string + remote string + want string + }{ + {name: "space in org", remote: "https://gitea.example.com/Company%20A/myrepo.git", want: "Company A/myrepo"}, + {name: "encoded slash in github org", remote: "https://github.com/org%2Fname/repo.git", want: "org/name/repo"}, + {name: "no encoding", remote: "[email protected]:owner/repo.git", want: "owner/repo"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init") + runGit(t, repo, "remote", "add", "origin", tc.remote) + withCwd(t, repo) + + service := NewService(func(string, string) {}, func(string, string, string) {}) + assert.Equal(t, tc.want, service.ResolveRepoName(context.Background())) + }) + } + }) + t.Run("resolve local key when no remote is configured", func(t *testing.T) { tmpDir := t.TempDir() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/lazyworktree-1.45.0/lazyworktree.1 new/lazyworktree-1.45.1/lazyworktree.1 --- old/lazyworktree-1.45.0/lazyworktree.1 2026-03-28 18:26:04.000000000 +0100 +++ new/lazyworktree-1.45.1/lazyworktree.1 2026-04-28 15:17:12.000000000 +0200 @@ -66,6 +66,12 @@ Override the default worktree root directory. .br Default: ~/.local/share/worktrees +.br +\fB$LWT_REPO_PATH\fR is automatically set to the git repository root, enabling +repo-local placement: \fI$LWT_REPO_PATH/.worktrees\fR. When the directory is +inside the repository, the \fI<repoName>\fR segment is omitted so worktrees +land directly under the configured directory. Add the directory to +\fI.gitignore\fR so it does not appear in \fBgit status\fR. . .TP .B \-\-theme \fINAME\fR @@ -666,7 +672,7 @@ . .TP .B X -Prune merged worktrees. Automatically refreshes PR/MR data from GitHub or GitLab (if connected), then detects worktrees whose associated PR has been merged or whose branch has been merged into the main branch. For repositories without GitHub/GitLab remotes, uses git-based merge detection only. Displays a checklist allowing selection of which worktrees to remove. +Prune merged worktrees and stale branches. Automatically refreshes PR/MR data from GitHub or GitLab (if connected), then detects worktrees whose associated PR has been merged or whose branch has been merged into the main branch. For repositories without GitHub/GitLab remotes, uses git-based merge detection only. When \fBprune_stale_branches\fR is enabled, also includes local branches that are merged but have no associated worktree. Displays a checklist allowing selection of which items to remove. . .TP .B ! @@ -1010,12 +1016,13 @@ .nf # Set globally git config --global lw.theme nord -git config --global lw.worktree_dir ~/.local/share/worktrees +git config --global lw.worktree-dir ~/.local/share/worktrees # Set per-repository (overrides global) git config --local lw.theme dracula -git config --local lw.init_commands "link_topsymlinks" -git config --local lw.init_commands "npm install" # Multi-values supported +git config --local lw.worktree-dir '$LWT_REPO_PATH/.worktrees' # Repo-local worktrees +git config --local lw.init-commands "link_topsymlinks" +git config --local lw.init-commands "npm install" # Multi-values supported # View all lazyworktree settings git config --global --get-regexp "^lw\." ++++++ lazyworktree.obsinfo ++++++ --- /var/tmp/diff_new_pack.RcRVfx/_old 2026-04-30 20:30:40.739505767 +0200 +++ /var/tmp/diff_new_pack.RcRVfx/_new 2026-04-30 20:30:40.795508044 +0200 @@ -1,5 +1,5 @@ name: lazyworktree -version: 1.45.0 -mtime: 1774718764 -commit: 0d2cb7b5d6f1a57f66620ed34e02b3d9c996a5cd +version: 1.45.1 +mtime: 1777382232 +commit: 931d43166ed25419350a6b2f7c4e2c5db0aeb91e ++++++ vendor.tar.gz ++++++ ++++ 4475 lines of diff (skipped)
