wtr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 95f22dbf8fd43c6bbab7163b62aca82e614ec28e76a0756703887ef996c6c228
4
+ data.tar.gz: e1bb5116ced20e3b42f2d9dec7d17140084f3428d83d55fdf7524c73d4d8228a
5
+ SHA512:
6
+ metadata.gz: aa83072d28ccdf3eb89046b09ee1364e5f2daecb3e65ac5c39c21564a01a3e8c3e774b68c63e06057ebb0b3bbc38b2d33eb36878367515fed8517b55ebf82b5a
7
+ data.tar.gz: 7bbb0e7452bad31d39e2c853be913d305fb443406acfe23c025c98f281e87fd9476394b1bb2ca71af9ff158f463ad8f6c35b38b28d876817a3096d7f20cf3e60
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-10-20
4
+
5
+ - Initial release: port of wt (Crystal) to Ruby, with `cd`, `new`, `rm [-b]`, `ls [-l]` and zsh integration
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "wtr" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["arzezak@gmail.com"](mailto:"arzezak@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ariel Rzezak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # 🌳💻 wtr
2
+
3
+ A small Ruby CLI for managing git worktrees. A port of [wt](https://github.com/arzezak/wt) from Crystal to Ruby.
4
+
5
+ ## Who this is for
6
+
7
+ wtr is for people who want to create, switch between, and remove worktrees by hand from the terminal, and keep an eye on what exists. If you want manual control over your worktrees rather than delegating that to automation, wtr is for you.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ gem install wtr
13
+ ```
14
+
15
+ Add to your `.zshrc`:
16
+
17
+ ```sh
18
+ eval "$(command wtr init zsh)"
19
+ ```
20
+
21
+ Reload your shell (`exec zsh`) and you're set.
22
+
23
+ ## Usage
24
+
25
+ Switch to a worktree (exact or unique-prefix match, tab-completes):
26
+
27
+ ```sh
28
+ wtr [name]
29
+ wtr cd <name>
30
+ wtr cd main # back to the main worktree (also matches the main branch name)
31
+ wtr cd - # back to the previous worktree
32
+ ```
33
+
34
+ Create a worktree, optionally from a base ref:
35
+
36
+ ```sh
37
+ wtr new <branch> [base]
38
+ ```
39
+
40
+ When the branch exists, `wtr new` checks it out into a new worktree. When the branch is new, it creates it from `[base]`, or from the current `HEAD` if no base is provided. Either way, it switches into the worktree when it finishes.
41
+
42
+ Remove a worktree (branch preserved):
43
+
44
+ ```sh
45
+ wtr rm [name]
46
+ ```
47
+
48
+ Pass `-b`/`--branch` to also delete the worktree's branch. This force-deletes it, so it works even when the branch isn't merged:
49
+
50
+ ```sh
51
+ wtr rm [name] -b
52
+ ```
53
+
54
+ List worktrees (`-l`/`--long` adds the HEAD sha):
55
+
56
+ ```sh
57
+ wtr ls
58
+ ```
59
+
60
+ Other commands:
61
+
62
+ ```sh
63
+ wtr help
64
+ wtr --version
65
+ ```
66
+
67
+ ### Resolution
68
+
69
+ When you pass a name, `wtr` resolves it directly: exact match first, then unique prefix, then error with candidates.
70
+
71
+ When you omit the name, `wtr` lists the available worktrees. `wtr cd main` returns to the main worktree, and `wtr cd -` returns to the worktree you came from.
72
+
73
+ ### Tab completion
74
+
75
+ Tab completion is set up automatically by `wtr init zsh`. It completes:
76
+
77
+ - `wtr cd <tab>` / `wtr rm <tab>`: worktree names
78
+ - `wtr new <tab>`: branch names
79
+ - `wtr new <branch> <tab>`: branch/ref names for the base
80
+
81
+ ## Configuration
82
+
83
+ Per-repo `.wtr.yml` (committed, shared with the team) with an optional `.wtr.local.yml` override (gitignored, personal). Global defaults in `~/.config/wtr/config.yml`. Merge order: global, repo, local.
84
+
85
+ ```yaml
86
+ copy:
87
+ - .env
88
+ - .env.local
89
+ - config/master.key
90
+
91
+ after_create:
92
+ - bundle install
93
+ - yarn install
94
+ ```
95
+
96
+ ### `copy`
97
+
98
+ Files to copy from the main worktree into the new one. These are typically gitignored, so they don't come across with the checkout. Missing sources are skipped with a warning.
99
+
100
+ ### `after_create`
101
+
102
+ Commands run in the new worktree's directory, in order, streaming output. Stops on first non-zero exit. The worktree is already created, so it stays in place for you to fix manually.
103
+
104
+ Each command is announced on stderr with a 🌳 prefix (as are `copy` files) so it's easy to tell wtr's own output apart from the command's.
105
+
106
+ Each command runs with `WTR_WORKTREE_NAME` set to the worktree's directory name (e.g. `feature-foo`), useful for isolating per-worktree resources like a database name.
107
+
108
+ For example, giving each worktree its own Rails development/test database:
109
+
110
+ ```yaml
111
+ after_create:
112
+ - |
113
+ cat > .env.local << ENV
114
+ DB_DATABASE=${WTR_WORKTREE_NAME}_development
115
+ DB_TEST_DATABASE=${WTR_WORKTREE_NAME}_test
116
+ ENV
117
+ - bundle install
118
+ - yarn install
119
+ - bin/rails db:create db:schema:load
120
+ ```
121
+
122
+ ### `--no-hooks`
123
+
124
+ Skip all copy and after_create steps on `wtr new`.
125
+
126
+ ### Trust model
127
+
128
+ `.wtr.yml` can be committed to a repo, and `after_create` runs arbitrary shell commands. This is the same trust model as Makefiles and npm scripts: review `.wtr.yml` before running `wtr new` in an untrusted repo, or pass `--no-hooks`.
129
+
130
+ ## Shell integration
131
+
132
+ ### The cd problem
133
+
134
+ A program can't `cd` your shell (it's a child process). So `wtr` is an **executable + thin shim**, like zoxide or rbenv:
135
+
136
+ - The **executable** holds all logic (resolve paths, create/remove worktrees, format output).
137
+ - A tiny **shell function** calls the executable and `eval`s its stdout for navigation commands.
138
+
139
+ Both are named `wtr`. The function shadows the executable for interactive use and delegates with `command wtr` (which bypasses functions and runs the executable on PATH).
140
+
141
+ ### Stdout protocol
142
+
143
+ The executable emits two kinds of output on stdout:
144
+
145
+ 1. **Directives** the shim `eval`s (e.g. `cd <path>`)
146
+ 2. **Plain text** the shim prints as-is (ls, help, messages)
147
+
148
+ Errors and human messages go to stderr so they never get `eval`d and hook output (bundle install, etc.) streams live.
149
+
150
+ ## Worktree conventions
151
+
152
+ - Worktrees live in `<repo>/.worktrees/<branch>`.
153
+ - `/` in branch names becomes `-` for the directory (`feature/foo` becomes `.worktrees/feature-foo`).
154
+ - `.worktrees/` is added to `.git/info/exclude` (local-only, never the committed `.gitignore`).
155
+
156
+ ## Development
157
+
158
+ ```sh
159
+ bin/setup
160
+ bundle exec rake # tests + standard
161
+ ```
162
+
163
+ The unit tests exercise the library directly; the command tests drive `exe/wtr` in throwaway git repos. Point `WTR_BIN` at another executable to run them against it instead.
164
+
165
+ The code sticks to the Ruby subset supported by [Spinel](https://github.com/matz/spinel), Matz's ahead-of-time compiler, so it can also be compiled to a native binary. Keep new code inside that subset.
166
+
167
+ ## License
168
+
169
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[test standard]
data/exe/wtr ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative "../lib/wtr"
3
+
4
+ Wtr::CLI.new.run(ARGV.dup)
data/lib/wtr/cli.rb ADDED
@@ -0,0 +1,110 @@
1
+ module Wtr
2
+ class CLI
3
+ HELP = <<~HELP
4
+ wtr — git worktrees
5
+
6
+ USAGE
7
+ wtr [cd] [name] switch to a worktree (resolve by name, tab-completes)
8
+ wtr new <branch> [base] create/checkout a worktree, cd into it
9
+ wtr rm [name] [-b] remove a worktree (-b/--branch also deletes its branch)
10
+ wtr ls [-l] list all worktrees (-l/--long adds the HEAD sha)
11
+ wtr help this text
12
+
13
+ WHERE
14
+ worktrees live in <repo>/.worktrees/<branch> (auto git-ignored; slashes become dashes)
15
+
16
+ cd
17
+ bare "wtr" is "wtr cd". with a name, resolves by exact match then unique prefix.
18
+ with no name, lists available worktrees.
19
+ "wtr cd main" returns to the main worktree (also matches the main branch name).
20
+ "wtr cd -" returns to the previous worktree.
21
+ errors out if the repo only has one worktree (nothing to switch to).
22
+
23
+ new
24
+ branch exists: checks it out into the new worktree
25
+ branch is new: creates it from <base> (default: current HEAD)
26
+ then cd into the worktree.
27
+ note: git refuses a branch already checked out elsewhere (e.g. main).
28
+ pass --no-hooks to skip copy + after_create steps.
29
+
30
+ rm
31
+ removes the picked worktree but preserves its branch.
32
+ refuses the main worktree; git refuses if there are uncommitted changes.
33
+ pass -b/--branch to also delete the branch (force-deletes, even if unmerged).
34
+ HELP
35
+
36
+ def initialize
37
+ @worktrees = nil
38
+ end
39
+
40
+ def run(args)
41
+ dispatch(args).render($stdout)
42
+ rescue => e
43
+ Log.puts(e.message)
44
+ exit 1
45
+ end
46
+
47
+ private
48
+
49
+ def dispatch(args)
50
+ subcommand = args.empty? ? "cd" : args.shift
51
+
52
+ case subcommand
53
+ when "help", "-h", "--help"
54
+ Result.print(HELP)
55
+ when "--version", "-v"
56
+ Result.print("wtr #{VERSION}")
57
+ when "init"
58
+ Completion.init_script(args.first || "zsh")
59
+ when "cd"
60
+ Commands::Cd.new(worktrees).run(args.first)
61
+ when "new"
62
+ dispatch_new(args)
63
+ when "rm"
64
+ dispatch_rm(args)
65
+ when "ls", "list"
66
+ dispatch_ls(args)
67
+ when "__complete"
68
+ Completion.new(worktrees).complete(args.first || "subcommands")
69
+ else
70
+ raise "unknown subcommand '#{subcommand}'\n#{HELP}"
71
+ end
72
+ end
73
+
74
+ def dispatch_ls(args)
75
+ long = flag?(args, "-l", "--long")
76
+ reject_unknown_flags(args, "ls")
77
+
78
+ Commands::Ls.new(worktrees).run(long: long)
79
+ end
80
+
81
+ def dispatch_rm(args)
82
+ delete_branch = flag?(args, "-b", "--branch")
83
+ reject_unknown_flags(args, "rm")
84
+
85
+ Commands::Rm.new(worktrees).run(args.first, delete_branch: delete_branch)
86
+ end
87
+
88
+ def dispatch_new(args)
89
+ hooks = !flag?(args, "--no-hooks")
90
+ branch = args.shift
91
+ base = args.shift
92
+ raise "usage: wtr new <branch> [base] [--no-hooks]" unless branch
93
+
94
+ Commands::New.new(worktrees).run(branch, base, hooks: hooks)
95
+ end
96
+
97
+ def flag?(args, *names)
98
+ names.map { |name| args.delete(name) }.compact.any? # Spinel: matz/spinel#9883
99
+ end
100
+
101
+ def reject_unknown_flags(args, subcommand)
102
+ unknown = args.find { |arg| arg.start_with?("-") }
103
+ raise "unknown flag '#{unknown}' for #{subcommand}" if unknown
104
+ end
105
+
106
+ def worktrees
107
+ @worktrees ||= Worktrees.new
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,39 @@
1
+ module Wtr
2
+ module Commands
3
+ class Cd
4
+ def initialize(worktrees)
5
+ @worktrees = worktrees
6
+ end
7
+
8
+ def run(query = nil)
9
+ return cd_previous if query == "-"
10
+
11
+ Result.cd(@worktrees.find(query, main_alias: true).path)
12
+ rescue Worktrees::NothingToPick
13
+ Log.puts "only one worktree here, use 'wtr new <branch>' to create another"
14
+ Result.none
15
+ rescue Worktrees::NameRequired => e
16
+ Log.puts e.message
17
+ Result.none
18
+ end
19
+
20
+ private
21
+
22
+ def cd_previous
23
+ previous = ENV["WTR_PREV"]
24
+
25
+ if previous.nil? || previous.strip.empty?
26
+ Log.puts "no previous worktree"
27
+ return Result.none
28
+ end
29
+
30
+ unless Dir.exist?(previous)
31
+ Log.puts "previous worktree is gone: #{previous}"
32
+ return Result.none
33
+ end
34
+
35
+ Result.cd(previous)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,56 @@
1
+ module Wtr
2
+ module Commands
3
+ class Ls
4
+ def initialize(worktrees)
5
+ @worktrees = worktrees
6
+ end
7
+
8
+ def run(long: false)
9
+ worktrees = @worktrees.all
10
+ return Result.print("no worktrees") if worktrees.empty?
11
+
12
+ rows = [header(long)] + worktrees.map { |worktree| row_for(worktree, long) }
13
+ Result.print(render_table(rows))
14
+ end
15
+
16
+ private
17
+
18
+ def header(long)
19
+ long ? ["BRANCH", "HEAD", "PATH"] : ["BRANCH", "PATH"]
20
+ end
21
+
22
+ def row_for(worktree, long)
23
+ columns = [branch_label(worktree)]
24
+ columns << worktree.short_head if long
25
+ columns << tilde(worktree.path)
26
+ columns
27
+ end
28
+
29
+ def render_table(rows)
30
+ widths = column_widths(rows)
31
+ lines = rows.map do |row|
32
+ cells = []
33
+ row.each_with_index do |cell, i|
34
+ cells << ((i < widths.size) ? cell.ljust(widths[i]) : cell)
35
+ end
36
+ cells.join(" ")
37
+ end
38
+ lines.join("\n")
39
+ end
40
+
41
+ def column_widths(rows)
42
+ (0...rows.first.size - 1).map { |i| rows.map { |row| row[i].size }.max }
43
+ end
44
+
45
+ def tilde(path)
46
+ home = Dir.home
47
+ path.start_with?(home) ? "~#{path[home.size..-1]}" : path # standard:disable Style/SlicingWithRange -- Spinel
48
+ end
49
+
50
+ def branch_label(worktree)
51
+ label = worktree.branch || "(detached)"
52
+ worktree.main? ? "#{label} *" : label
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,81 @@
1
+ require "fileutils"
2
+
3
+ module Wtr
4
+ module Commands
5
+ class New
6
+ def initialize(worktrees)
7
+ @worktrees = worktrees
8
+ end
9
+
10
+ def run(branch, base = nil, hooks: true)
11
+ added = @worktrees.create(branch, base: base)
12
+
13
+ if added.existed
14
+ Log.puts "worktree already exists at #{added.path}"
15
+ elsif hooks
16
+ run_hooks(@worktrees.main.path, added.path)
17
+ end
18
+
19
+ Result.cd(added.path)
20
+ end
21
+
22
+ private
23
+
24
+ def run_hooks(main_path, worktree_path)
25
+ config = Config.load(main_path)
26
+ copy_files(config, main_path, worktree_path)
27
+ run_after_create(config, worktree_path)
28
+ end
29
+
30
+ def copy_files(config, main_path, worktree_path)
31
+ main_root = File.realpath(main_path)
32
+ worktree_root = File.realpath(worktree_path)
33
+
34
+ config.copy.each do |relative_path|
35
+ source = File.expand_path(relative_path, main_path)
36
+ destination = File.expand_path(relative_path, worktree_path)
37
+ unless File.exist?(source)
38
+ Log.puts "copy: skipping #{relative_path} (not found in main worktree)"
39
+ next
40
+ end
41
+ unless within?(File.realpath(source), main_root) && within?(existing_ancestor(destination), worktree_root)
42
+ Log.puts "copy: skipping #{relative_path} (path escapes repo)"
43
+ next
44
+ end
45
+ if File.symlink?(destination)
46
+ Log.puts "copy: skipping #{relative_path} (destination is a symlink)"
47
+ next
48
+ end
49
+ FileUtils.mkdir_p(File.dirname(destination))
50
+ FileUtils.cp(source, destination)
51
+ Log.step("copy", relative_path)
52
+ end
53
+ end
54
+
55
+ def within?(path, root)
56
+ path == root || path.start_with?(root + "/")
57
+ end
58
+
59
+ def existing_ancestor(path)
60
+ dir = File.dirname(path)
61
+ dir = File.dirname(dir) until File.exist?(dir)
62
+ File.realpath(dir)
63
+ end
64
+
65
+ def run_after_create(config, worktree_path)
66
+ env = {"WTR_WORKTREE_NAME" => File.basename(worktree_path)}
67
+
68
+ config.after_create.each do |command|
69
+ Log.step("run", command)
70
+ pid = Process.spawn(env, "sh", "-c", command, chdir: worktree_path, out: $stderr, err: $stderr)
71
+ Process.wait(pid)
72
+ status = $?
73
+ unless status.success?
74
+ Log.puts "after_create failed: #{command} (exit #{status.exitstatus})"
75
+ break
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,44 @@
1
+ module Wtr
2
+ module Commands
3
+ class Rm
4
+ def initialize(worktrees)
5
+ @worktrees = worktrees
6
+ end
7
+
8
+ def run(query = nil, delete_branch: false)
9
+ worktree = @worktrees.find(query)
10
+ inside = worktree.contains?(Dir.pwd)
11
+ return Result.none if inside && !confirm_removal?(worktree)
12
+
13
+ deleted = @worktrees.remove(worktree, delete_branch: delete_branch)
14
+ Log.puts removal_message(worktree, delete_branch, deleted)
15
+
16
+ inside ? Result.cd(@worktrees.main.path) : Result.none
17
+ rescue Worktrees::NothingToPick
18
+ Log.puts "no worktrees to remove"
19
+ Result.none
20
+ rescue Worktrees::NameRequired => e
21
+ Log.puts e.message
22
+ Result.none
23
+ end
24
+
25
+ private
26
+
27
+ def removal_message(worktree, delete_branch, deleted)
28
+ if deleted
29
+ "removed #{worktree.name} and deleted branch #{deleted}"
30
+ elsif delete_branch
31
+ "removed #{worktree.name} (detached HEAD, no branch to delete)"
32
+ else
33
+ "removed #{worktree.name} (branch preserved)"
34
+ end
35
+ end
36
+
37
+ def confirm_removal?(worktree)
38
+ Log.print "you're inside #{worktree.name}, remove and cd to main? [y/n] "
39
+ answer = $stdin.gets
40
+ !answer.nil? && answer.strip.downcase == "y"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,90 @@
1
+ module Wtr
2
+ class Completion
3
+ def initialize(worktrees)
4
+ @worktrees = worktrees
5
+ end
6
+
7
+ def complete(kind)
8
+ candidates = case kind
9
+ when "worktrees"
10
+ @worktrees.names
11
+ when "cd_targets"
12
+ @worktrees.names(main_alias: true)
13
+ when "branches"
14
+ @worktrees.branches
15
+ when "refs"
16
+ @worktrees.refs
17
+ when "subcommands"
18
+ ["cd", "new", "rm", "ls", "help"]
19
+ else
20
+ []
21
+ end
22
+ Result.print(candidates.join("\n"))
23
+ end
24
+
25
+ def self.init_script(shell)
26
+ case shell
27
+ when "zsh"
28
+ Result.print(ZSH_INIT_SCRIPT)
29
+ else
30
+ Log.puts "unsupported shell '#{shell}' (available: zsh)"
31
+ Result.none
32
+ end
33
+ end
34
+
35
+ ZSH_INIT_SCRIPT = <<~'ZSH'
36
+ wtr() {
37
+ local output
38
+ output=$(command wtr "$@") || return
39
+
40
+ if [[ "$output" == cd\ * ]]; then
41
+ export WTR_PREV="$PWD"
42
+ eval "$output"
43
+ elif [[ -n "$output" ]]; then
44
+ print -r -- "$output"
45
+ fi
46
+ }
47
+
48
+ _wtr() {
49
+ local -a subcommands=(
50
+ 'cd:switch to a worktree'
51
+ 'new:create a worktree'
52
+ 'rm:remove a worktree'
53
+ 'ls:list worktrees'
54
+ 'help:show help'
55
+ )
56
+
57
+ if (( CURRENT == 2 )); then
58
+ _describe 'subcommand' subcommands
59
+ return
60
+ fi
61
+
62
+ case "${words[2]}" in
63
+ cd)
64
+ local -a worktrees
65
+ worktrees=(${(f)"$(command wtr __complete cd_targets)"})
66
+ _describe 'worktree' worktrees
67
+ ;;
68
+ rm)
69
+ local -a worktrees
70
+ worktrees=(${(f)"$(command wtr __complete worktrees)"})
71
+ _describe 'worktree' worktrees
72
+ ;;
73
+ new)
74
+ if (( CURRENT == 3 )); then
75
+ local -a branches
76
+ branches=(${(f)"$(command wtr __complete branches)"})
77
+ _describe 'branch' branches
78
+ elif (( CURRENT == 4 )); then
79
+ local -a refs
80
+ refs=(${(f)"$(command wtr __complete refs)"})
81
+ _describe 'ref' refs
82
+ fi
83
+ ;;
84
+ esac
85
+ }
86
+
87
+ compdef _wtr wtr
88
+ ZSH
89
+ end
90
+ end
data/lib/wtr/config.rb ADDED
@@ -0,0 +1,56 @@
1
+ require "yaml"
2
+
3
+ module Wtr
4
+ class Config
5
+ attr_reader :copy, :after_create
6
+
7
+ def initialize(copy: [], after_create: [])
8
+ @copy = copy
9
+ @after_create = after_create
10
+ end
11
+
12
+ def self.load(main_repo_path)
13
+ global = load_file(global_path)
14
+ repo = load_file(File.join(main_repo_path, ".wtr.yml"))
15
+ local = load_file(File.join(main_repo_path, ".wtr.local.yml"))
16
+ merge([global, repo, local])
17
+ end
18
+
19
+ def self.global_path
20
+ config_home = ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
21
+ File.join(config_home, "wtr", "config.yml")
22
+ end
23
+
24
+ def self.load_file(path)
25
+ return Config.new unless File.exist?(path)
26
+
27
+ parse(File.read(path))
28
+ rescue Psych::SyntaxError => e
29
+ Log.puts "failed to parse #{path}: #{e.message}"
30
+ Config.new
31
+ end
32
+
33
+ def self.parse(content)
34
+ yaml = YAML.safe_load(content)
35
+ yaml = {} unless yaml.is_a?(Hash)
36
+ Config.new(copy: string_array(yaml, "copy"), after_create: string_array(yaml, "after_create"))
37
+ end
38
+
39
+ def self.string_array(yaml, key)
40
+ node = yaml[key]
41
+ return [] unless node.is_a?(Array) && node.all? { |item| item.is_a?(String) }
42
+
43
+ node
44
+ end
45
+
46
+ def self.merge(configs)
47
+ copy = []
48
+ after_create = []
49
+ configs.each do |config|
50
+ copy = config.copy unless config.copy.empty?
51
+ after_create = config.after_create unless config.after_create.empty?
52
+ end
53
+ Config.new(copy: copy, after_create: after_create)
54
+ end
55
+ end
56
+ end
data/lib/wtr/log.rb ADDED
@@ -0,0 +1,15 @@
1
+ module Wtr
2
+ module Log
3
+ def self.puts(message)
4
+ warn message
5
+ end
6
+
7
+ def self.print(message)
8
+ $stderr.print message
9
+ end
10
+
11
+ def self.step(label, subject)
12
+ warn "🌳 #{label}: #{subject}"
13
+ end
14
+ end
15
+ end
data/lib/wtr/result.rb ADDED
@@ -0,0 +1,35 @@
1
+ module Wtr
2
+ class Result
3
+ def initialize(cd_path: nil, stdout: nil)
4
+ @cd_path = cd_path
5
+ @stdout = stdout
6
+ end
7
+
8
+ def self.cd(path)
9
+ new(cd_path: path)
10
+ end
11
+
12
+ def self.print(text)
13
+ new(stdout: text)
14
+ end
15
+
16
+ def self.none
17
+ new
18
+ end
19
+
20
+ def render(io)
21
+ if @cd_path
22
+ io.puts "cd #{Result.shell_quote(@cd_path)}"
23
+ elsif @stdout
24
+ io.puts @stdout
25
+ end
26
+ end
27
+
28
+ def self.shell_quote(arg)
29
+ return "''" if arg.empty?
30
+ return arg unless arg.match?(/[^a-zA-Z0-9%+,\-.\/:=@_]/)
31
+
32
+ "'" + arg.gsub("'", %('"'"')) + "'"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wtr
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,190 @@
1
+ require "fileutils"
2
+ require "open3"
3
+
4
+ module Wtr
5
+ class Worktrees
6
+ Worktree = Data.define(:path, :head, :branch, :main) do
7
+ def main?
8
+ main
9
+ end
10
+
11
+ def name
12
+ File.basename(path)
13
+ end
14
+
15
+ def short_head
16
+ head[0, 7]
17
+ end
18
+
19
+ def contains?(dir)
20
+ dir == path || dir.start_with?(path + "/")
21
+ end
22
+ end
23
+
24
+ Added = Data.define(:path, :existed)
25
+
26
+ DIRECTORY = ".worktrees"
27
+
28
+ class Error < StandardError; end
29
+
30
+ class GitError < Error; end
31
+
32
+ class NothingToPick < Error; end
33
+
34
+ class NameRequired < Error
35
+ def initialize(names)
36
+ super("pass a name (tab-completes): #{names.join(", ")}")
37
+ end
38
+ end
39
+
40
+ class Ambiguous < Error; end
41
+
42
+ class NotFound < Error; end
43
+
44
+ def self.resolve(query, candidates)
45
+ raise NothingToPick if candidates.empty?
46
+
47
+ names = candidates.map(&:name)
48
+ raise NameRequired.new(names) if query.nil? || query.strip.empty?
49
+
50
+ exact = candidates.find { |worktree| worktree.name == query }
51
+ return exact if exact
52
+
53
+ matches = candidates.select { |worktree| worktree.name.start_with?(query) }
54
+ return matches.first if matches.size == 1
55
+
56
+ if matches.size > 1
57
+ raise Ambiguous, "ambiguous name '#{query}', matches: #{matches.map(&:name).join(", ")}"
58
+ end
59
+
60
+ raise NotFound, "no worktree matching '#{query}' (available: #{names.join(", ")})"
61
+ end
62
+
63
+ def initialize
64
+ @all = nil
65
+ @main = nil
66
+ end
67
+
68
+ def all
69
+ @all ||= parse_list(git("worktree", "list", "--porcelain"))
70
+ end
71
+
72
+ def main
73
+ @main ||= all.find(&:main?) || raise(Error, "cannot find the main worktree")
74
+ end
75
+
76
+ def names(main_alias: false)
77
+ names = others.map(&:name)
78
+ main_alias ? names + ["main"] : names
79
+ end
80
+
81
+ def find(query, main_alias: false)
82
+ return main if main_alias && main_query?(query)
83
+
84
+ Worktrees.resolve(query, others)
85
+ end
86
+
87
+ def create(branch, base: nil)
88
+ root = File.join(main.path, DIRECTORY)
89
+ path = File.join(root, branch.tr("/", "-"))
90
+ return Added.new(path: path, existed: true) if Dir.exist?(path)
91
+
92
+ FileUtils.mkdir_p(root)
93
+ ensure_ignored
94
+ if branch_exists?(branch)
95
+ git("worktree", "add", path, branch)
96
+ else
97
+ args = ["worktree", "add", "-b", branch, path]
98
+ args << base if base
99
+ git(*args)
100
+ end
101
+ @all = nil
102
+ Added.new(path: path, existed: false)
103
+ end
104
+
105
+ def remove(worktree, delete_branch: false)
106
+ raise Error, "refusing to remove the main worktree" if worktree.main?
107
+
108
+ main_path = main.path
109
+ git("worktree", "remove", worktree.path)
110
+ @all = nil
111
+ return nil unless delete_branch && worktree.branch
112
+
113
+ # Run from the main repo: the removed worktree may have been the cwd.
114
+ git("-C", main_path, "branch", "-D", worktree.branch)
115
+ worktree.branch
116
+ end
117
+
118
+ def branches
119
+ lines(git("branch", "--format=%(refname:short)"))
120
+ end
121
+
122
+ def refs
123
+ lines(git("for-each-ref", "--format=%(refname:short)", "refs/heads/", "refs/tags/", "refs/remotes/"))
124
+ end
125
+
126
+ private
127
+
128
+ def others
129
+ all.reject(&:main?)
130
+ end
131
+
132
+ def main_query?(query)
133
+ query == "main" || (!query.nil? && query == main.branch)
134
+ end
135
+
136
+ def ensure_ignored
137
+ marker = "#{DIRECTORY}/"
138
+ exclude_file = git("rev-parse", "--path-format=absolute", "--git-path", "info/exclude")
139
+ return if File.exist?(exclude_file) && File.read(exclude_file).lines.any? { |line| line.strip == marker }
140
+
141
+ FileUtils.mkdir_p(File.dirname(exclude_file))
142
+ File.open(exclude_file, "a") { |file| file.puts marker }
143
+ end
144
+
145
+ def branch_exists?(branch)
146
+ system("git", "show-ref", "--verify", "--quiet", "refs/heads/#{branch}") == true
147
+ end
148
+
149
+ def git(*args)
150
+ stdout, stderr, status = Open3.capture3("git", *args)
151
+ raise GitError, (stderr.strip.empty? ? "git command failed" : stderr.strip) unless status.success?
152
+
153
+ stdout.strip
154
+ end
155
+
156
+ def lines(output)
157
+ output.split("\n").reject(&:empty?)
158
+ end
159
+
160
+ def parse_list(output)
161
+ worktrees = []
162
+ output.split("\n\n").each do |stanza|
163
+ # git lists the main worktree first.
164
+ worktree = parse_stanza(stanza, main: worktrees.empty?)
165
+ worktrees << worktree if worktree
166
+ end
167
+ worktrees
168
+ end
169
+
170
+ def parse_stanza(stanza, main:)
171
+ path = ""
172
+ head = ""
173
+ branch = nil
174
+
175
+ stanza.each_line do |raw|
176
+ line = raw.chomp
177
+ if line.start_with?("worktree ")
178
+ path = line.delete_prefix("worktree ")
179
+ elsif line.start_with?("HEAD ")
180
+ head = line.delete_prefix("HEAD ")
181
+ elsif line.start_with?("branch ")
182
+ branch = line.delete_prefix("branch refs/heads/")
183
+ end
184
+ end
185
+ return nil if path.empty?
186
+
187
+ Worktree.new(path: path, head: head, branch: branch, main: main)
188
+ end
189
+ end
190
+ end
data/lib/wtr.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wtr/version"
4
+ require_relative "wtr/log"
5
+ require_relative "wtr/result"
6
+ require_relative "wtr/worktrees"
7
+ require_relative "wtr/config"
8
+ require_relative "wtr/completion"
9
+ require_relative "wtr/commands/cd"
10
+ require_relative "wtr/commands/ls"
11
+ require_relative "wtr/commands/new"
12
+ require_relative "wtr/commands/rm"
13
+ require_relative "wtr/cli"
data/sig/wtr.rbs ADDED
@@ -0,0 +1,3 @@
1
+ module Wtr
2
+ VERSION: String
3
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wtr
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ariel Rzezak
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Create, switch between, and remove git worktrees from the terminal, with
13
+ a zsh wrapper that cds into them.
14
+ email:
15
+ - arzezak@gmail.com
16
+ executables:
17
+ - wtr
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - CODE_OF_CONDUCT.md
23
+ - LICENSE.txt
24
+ - README.md
25
+ - Rakefile
26
+ - exe/wtr
27
+ - lib/wtr.rb
28
+ - lib/wtr/cli.rb
29
+ - lib/wtr/commands/cd.rb
30
+ - lib/wtr/commands/ls.rb
31
+ - lib/wtr/commands/new.rb
32
+ - lib/wtr/commands/rm.rb
33
+ - lib/wtr/completion.rb
34
+ - lib/wtr/config.rb
35
+ - lib/wtr/log.rb
36
+ - lib/wtr/result.rb
37
+ - lib/wtr/version.rb
38
+ - lib/wtr/worktrees.rb
39
+ - sig/wtr.rbs
40
+ homepage: https://github.com/arzezak/wtr
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ allowed_push_host: https://rubygems.org
45
+ homepage_uri: https://github.com/arzezak/wtr
46
+ source_code_uri: https://github.com/arzezak/wtr
47
+ changelog_uri: https://github.com/arzezak/wtr/blob/main/CHANGELOG.md
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 3.2.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 4.0.23
63
+ specification_version: 4
64
+ summary: A small CLI for managing git worktrees.
65
+ test_files: []